This commit is contained in:
Zotov Ivan Yuryevich
2026-04-02 23:08:29 +03:00
commit 64ae1d9f5a
124 changed files with 18491 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
.DS_Store
node_modules
/build
/package
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vscode/*
!.vscode/settings.json
/.idea
/coverage
/test-results
/allure-report
/allure-results
/.cache
*.log
/dist
/storybook-static
!.vscode/snippets.code-snippets
*.env
/test-results/
/playwright-report/
/playwright/.cache/
/infra/certs/
/.svelte-kit/**
!/.svelte-kit/tsconfig.json
src/i18n/*.json
### Playwright
/test-results/
/playwright-report/
/playwright/.cache/
results.json
/tests/**/*-darwin.png
/failed_tests.txt
__f2f_cookies.json

11
README.md Normal file
View File

@@ -0,0 +1,11 @@
# Redesign Modern Website (Copy)
This is a code bundle for Redesign Modern Website (Copy). The original project is available at https://www.figma.com/design/YgZy1khwB5JnJFfONCi1cA/Redesign-Modern-Website--Copy-.
## Running the code
Run `npm i` to install the dependencies.
Run `npm run dev` to start the development server.

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redesign Modern Website (Copy)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4910
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "Redesign Modern Website",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@vitejs/plugin-react": "*",
"class-variance-authority": "^0.7.1",
"clsx": "*",
"cmdk": "^1.1.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.487.0",
"next-themes": "^0.4.6",
"path": "*",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"sonner": "^2.0.3",
"tailwind-merge": "*",
"tailwindcss": "*",
"tw-animate-css": "^1.3.8",
"vaul": "^1.1.2",
"vite": "*"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.12",
"@types/node": "^20.10.0",
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "4.1.12",
"vite": "6.3.5"
},
"scripts": {
"dev": "vite",
"build": "vite build"
}
}

159
src/App.tsx Normal file
View File

@@ -0,0 +1,159 @@
import { useState, useEffect } from "react";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
import { ContactModal } from "./components/ContactModal";
import { HomePage } from "./pages/HomePage";
import { CloudPage } from "./pages/CloudPage";
import { RussianSoftwarePage } from "./pages/RussianSoftwarePage";
import { CybersecurityPage } from "./pages/CybersecurityPage";
import { AISolutionsPage } from "./pages/AISolutionsPage";
import { RussianHardwarePage } from "./pages/RussianHardwarePage";
import { HardwareSolutionsPage } from "./pages/HardwareSolutionsPage";
import { CADandGISPage } from "./pages/CADandGISPage";
import { IndustrialSolutionsPage } from "./pages/IndustrialSolutionsPage";
import { GovernmentPage } from "./pages/GovernmentPage";
import { BusinessPage } from "./pages/BusinessPage";
import { ConsultingPage } from "./pages/ConsultingPage";
import { ImplementationPage } from "./pages/ImplementationPage";
import { ManagedServicesPage } from "./pages/ManagedServicesPage";
import { IntegrationPage } from "./pages/IntegrationPage";
import { SpecializedServicesPage } from "./pages/SpecializedServicesPage";
import { SupportPage } from "./pages/SupportPage";
import { SubscriptionPage } from "./pages/SubscriptionPage";
export default function App() {
const [currentPage, setCurrentPage] = useState("home");
const [pendingScroll, setPendingScroll] = useState<
string | null
>(null);
const [isContactModalOpen, setIsContactModalOpen] =
useState(false);
const scrollToSection = (sectionId: string) => {
// Даем странице время отрендериться
setTimeout(() => {
const section = document.getElementById(sectionId);
console.log(
"Trying to scroll to:",
sectionId,
"Found element:",
section,
);
if (section) {
const headerHeight = 85; // Header высота + минимальный отступ
const targetPosition = section.offsetTop - headerHeight;
console.log("Scrolling to position:", targetPosition);
window.scrollTo({
top: targetPosition,
behavior: "smooth",
});
} else {
console.error("Section not found:", sectionId);
}
}, 100);
};
const handleNavigate = (page: string, section?: string) => {
if (section) {
// Клик на Кейсы/О компании/Контакты
if (currentPage !== "home") {
// Если не на главной - переходим туда
setCurrentPage("home");
setPendingScroll(section);
} else {
// Уже на главной - скроллим сразу
scrollToSection(section);
}
} else {
// Обычная навигация на другие страницы
setCurrentPage(page);
setPendingScroll(null);
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
useEffect(() => {
// Когда вернулись на главную с отложенным скроллом
if (currentPage === "home" && pendingScroll) {
scrollToSection(pendingScroll);
setPendingScroll(null);
}
}, [currentPage, pendingScroll]);
const handleBackToHome = () => {
setCurrentPage("home");
setPendingScroll(null);
window.scrollTo({ top: 0, behavior: "smooth" });
};
const renderPage = () => {
switch (currentPage) {
case "cloud":
return <CloudPage onBack={handleBackToHome} />;
case "russian-software":
return (
<RussianSoftwarePage onBack={handleBackToHome} />
);
case "cybersecurity":
return <CybersecurityPage onBack={handleBackToHome} />;
case "ai-solutions":
return <AISolutionsPage onBack={handleBackToHome} />;
case "russian-hardware":
return (
<RussianHardwarePage onBack={handleBackToHome} />
);
case "hardware-solutions":
return (
<HardwareSolutionsPage onBack={handleBackToHome} />
);
case "cad-gis":
return <CADandGISPage onBack={handleBackToHome} />;
case "industrial-solutions":
return (
<IndustrialSolutionsPage onBack={handleBackToHome} />
);
case "government":
return <GovernmentPage onBack={handleBackToHome} />;
case "business":
return <BusinessPage onBack={handleBackToHome} />;
case "consulting":
return <ConsultingPage onBack={handleBackToHome} />;
case "implementation":
return <ImplementationPage onBack={handleBackToHome} />;
case "managed-services":
return (
<ManagedServicesPage onBack={handleBackToHome} />
);
case "integration":
return <IntegrationPage onBack={handleBackToHome} />;
case "specialized-services":
return (
<SpecializedServicesPage onBack={handleBackToHome} />
);
case "support":
return <SupportPage onBack={handleBackToHome} />;
case "subscription":
return <SubscriptionPage onBack={handleBackToHome} />;
default:
return (
<HomePage
onOpenContactModal={() =>
setIsContactModalOpen(true)
}
/>
);
}
};
return (
<div className="min-h-screen bg-white w-full overflow-x-hidden">
<Header onNavigate={handleNavigate} />
{renderPage()}
<Footer />
<ContactModal
isOpen={isContactModalOpen}
onClose={() => setIsContactModalOpen(false)}
/>
</div>
);
}

3
src/Attributions.md Normal file
View File

@@ -0,0 +1,3 @@
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).

267
src/COMMIT-AND-DEPLOY.md Normal file
View File

@@ -0,0 +1,267 @@
# 🚀 Готово к коммиту и деплою!
## ✅ Все исправления применены
### Что было исправлено:
1. ✅ **Node.js версия зафиксирована на 18 LTS**
- Создан `.nvmrc`
- Добавлен `engines` в `package.json`
- `netlify.toml` уже содержал NODE_VERSION = "18"
2. ✅ **Vite настроен на сборку в папку `build`**
- Обновлён `vite.config.ts`: `outDir: 'build'`
- `netlify.toml` указывает `publish = "build"`
3. ✅ **Созданы скрипты проверки**
- `check-build.sh` (Linux/Mac)
- `check-build.bat` (Windows)
4. ✅ **Создан .gitignore**
- Исключены node_modules, логи, кеш
5. ✅ **Документация обновлена**
- NODE-VERSION-FIX.md
- NETLIFY-FIX.md
- LATEST-FIXES.md
- README.md
---
## 📋 Шаги для деплоя
### Шаг 1: Проверка локально (опционально)
**Убедитесь что используете Node.js 18:**
```bash
# Проверить версию
node -v
# Если не v18.x.x, установите:
nvm install 18
nvm use 18
```
**Запустите проверку сборки:**
```bash
# Linux/Mac
chmod +x check-build.sh
./check-build.sh
# Windows
check-build.bat
```
**Ожидаемый результат:**
```
✅ Node.js версия совместима (v18.x.x)
✅ BUILD OUTPUT: build
📋 Для Netlify используйте: publish = "build"
```
---
### Шаг 2: Коммит изменений
```bash
# Проверьте что изменилось
git status
# Добавьте все файлы
git add .
# Закоммитьте с понятным сообщением
git commit -m "Fix: Pin Node.js to v18 LTS and configure Vite outDir to 'build'"
# Альтернативный вариант (более детальный):
git commit -m "Fix deployment configuration
- Pin Node.js to v18 LTS (.nvmrc + package.json engines)
- Update vite.config.ts outDir from 'dist' to 'build'
- Update check-build scripts with Node version validation
- Add comprehensive deployment documentation"
```
---
### Шаг 3: Пуш в GitHub
```bash
# Запушьте в main (или master) ветку
git push origin main
# Или если используете master:
# git push origin master
```
---
### Шаг 4: Наблюдайте за деплоем в Netlify
После пуша:
1. **Откройте Netlify Dashboard**
- Перейдите на https://app.netlify.com
- Откройте ваш сайт
- Нажмите на **"Deploys"**
2. **Следите за логами**
- Должен начаться новый деплой (обычно в течение 10-30 секунд)
- Нажмите на последний деплой чтобы открыть логи
3. **Что искать в логах:**
**✅ Успешная сборка:**
```
v18.x.x is already installed.
Now using node v18.x.x (npm v9.x.x)
...
npm run build
...
vite v5.x.x building for production...
✓ built in X.XXs
build/assets/index-xxxxx.js
Deploy site
────────────────────────────────────────
✓ Deploy is live!
Website URL: https://your-site.netlify.app
```
**❌ Если ошибка:**
- Скопируйте полный текст ошибки
- См. раздел "Troubleshooting" ниже
---
## 🎉 После успешного деплоя
### Проверьте сайт:
1. **Откройте URL сайта** (из Netlify Dashboard)
2. **Проверьте основной функционал:**
- [ ] Главная страница загружается
- [ ] Навигация работает (Header меню)
- [ ] Переходы между страницами работают
- [ ] Стили применяются корректно
- [ ] Модальное окно "Обсудить проект" открывается
- [ ] Нет ошибок в консоли браузера (F12)
3. **Проверьте на мобильных устройствах:**
- [ ] Мобильное меню работает
- [ ] Responsive дизайн корректный
- [ ] Кнопки кликабельны
---
## 🔍 Troubleshooting
### Проблема 1: "Deploy directory 'build' does not exist"
**Решение:**
1. Проверьте логи сборки - успешно ли прошёл `npm run build`?
2. Если сборка падает, найдите строку с ошибкой
3. Попробуйте очистить кеш Netlify:
- Site settings → Build & deploy → Clear cache and retry deploy
### Проблема 2: Всё ещё используется Node.js v22
**Решение:**
1. Убедитесь что `.nvmrc` закоммичен: `git log --name-only -1`
2. Проверьте Netlify environment variables:
- Site settings → Environment variables
- Удалите переменную `NODE_VERSION` если она там есть (пусть используется .nvmrc)
3. Очистите кеш и retry deploy
### Проблема 3: Build успешен, но 404 или белый экран
**Решение:**
1. Проверьте что `publish` в netlify.toml правильный: `build`
2. В Site settings → Build & deploy → Build settings:
- Publish directory должна быть: `build`
3. Проверьте консоль браузера (F12) на наличие ошибок
### Проблема 4: Netlify всё ещё не работает
**Альтернативы:**
#### Cloudflare Pages (рекомендуется)
```
1. https://pages.cloudflare.com
2. Connect GitHub repository
3. Build command: npm run build
4. Build output: build
5. Environment: NODE_VERSION = 18
```
#### GitHub Pages (уже настроен)
```
1. GitHub repo → Settings → Pages
2. Source: GitHub Actions
3. Workflow автоматически задеплоится
```
---
## 📊 Ожидаемое время деплоя
- **Netlify:** ~2-3 минуты
- **Cloudflare Pages:** ~2-3 минуты
- **GitHub Pages:** ~3-5 минут
---
## 📖 Дополнительная документация
Если нужна дополнительная помощь:
- **NODE-VERSION-FIX.md** - детали о Node.js версии
- **NETLIFY-FIX.md** - решение проблем Netlify
- **LATEST-FIXES.md** - последние изменения
- **DEPLOYMENT.md** - полная инструкция по всем платформам
- **DEPLOY-QUICK.md** - быстрая шпаргалка
---
## 🎯 Команды для копирования
```bash
# Весь процесс одной командой:
node -v && \
git add . && \
git commit -m "Fix: Pin Node.js to v18 LTS and configure build output" && \
git push origin main && \
echo "✅ Pushed! Check Netlify Dashboard for deploy status."
```
---
## ✅ Финальный чеклист
- [x] Node.js 18 зафиксирован (.nvmrc, package.json)
- [x] vite.config.ts использует outDir: 'build'
- [x] netlify.toml использует publish: 'build'
- [x] Скрипты проверки обновлены
- [x] .gitignore создан
- [x] Документация обновлена
- [ ] **ВАШ ШАГ:** `git add .`
- [ ] **ВАШ ШАГ:** `git commit -m "..."`
- [ ] **ВАШ ШАГ:** `git push`
- [ ] **ПРОВЕРКА:** Netlify Deploy успешен
- [ ] **ПРОВЕРКА:** Сайт работает
---
## 🎊 Поздравляем!
После успешного деплоя ваш сайт SOFTCLICK будет доступен по URL от Netlify.
Вы также можете:
- Настроить **кастомный домен** (your-company.ru)
- Настроить **SSL сертификат** (HTTPS) - Netlify делает это автоматически
- Подключить **аналитику** (Yandex.Metrika, Google Analytics)
- Настроить **форму обратной связи** с реальной отправкой
**Удачи с проектом!** 🚀

124
src/DEPLOY-QUICK.md Normal file
View File

@@ -0,0 +1,124 @@
# 🚀 Быстрая шпаргалка по деплою
## ⚠️ ВАЖНО: Сначала проверьте сборку!
**Linux/Mac:**
```bash
./check-build.sh
```
**Windows:**
```cmd
check-build.bat
```
Это покажет, создаётся `dist` или `build`. Используйте правильную папку в настройках!
---
## 1️⃣ Netlify (РЕКОМЕНДУЕТСЯ - 2 минуты)
```
1. https://netlify.com → Sign up
2. "Add new site" → "Import from Git"
3. Выбрать репозиторий → Deploy
✅ Готово! Автодеплой включен
📝 netlify.toml уже настроен на папку build
Если нужно изменить - см. NETLIFY-FIX.md
```
## 2️⃣ Cloudflare Pages (Очень быстро)
```
1. https://pages.cloudflare.com → Sign up
2. "Create a project" → Подключить Git
3. Build: npm run build | Output: dist
✅ Готово!
```
## 3️⃣ GitHub Pages (100% бесплатно)
```
1. Запушить код в GitHub
2. Settings → Pages → Source: "GitHub Actions"
✅ Готово! Файл .github/workflows/deploy.yml уже настроен
```
## 4️⃣ Яндекс.Облако (Российское)
```bash
# Установка CLI
curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash
# Создание бакета
yc storage bucket create --name softclick --public-read
# Деплой
npm run build
yc storage s3api sync ./dist/ s3://softclick/
✅ Готово! ~100₽/месяц
```
## 5️⃣ Обычный хостинг (Timeweb, Beget и т.д.)
```bash
# Локально:
npm run build
# Загрузить через FTP содержимое папки dist/
# Файл .htaccess уже готов
✅ Готово! от 120₽/месяц
```
---
## ⚡ Супер-быстрая команда (для всех платформ)
```bash
npm run build
```
Загрузите содержимое папки `dist/` куда угодно!
---
## 🎯 Что выбрать?
- **Нужно быстро и бесплатно?** → Netlify
- **Нужен супер-быстрый CDN?** → Cloudflare Pages
- **100% бесплатно навсегда?** → GitHub Pages
- **Только российское?** → Яндекс.Облако
- **Есть свой хостинг?** → FTP + dist/
---
## 📱 Автоматические скрипты
**Linux/Mac:**
```bash
./deploy.sh
```
**Windows:**
```cmd
deploy.bat
```
---
## ❓ Проблемы?
1. Не работает `npm run build`?
```bash
rm -rf node_modules dist
npm install
npm run build
```
2. Белый экран после деплоя?
- Проверьте что загружены ВСЕ файлы из `dist/`
- Проверьте консоль браузера (F12)
3. 404 при переходе на страницы?
- Проверьте `.htaccess` (Apache)
- На Netlify/Cloudflare это работает автоматически
---
📖 **Полная инструкция:** `DEPLOYMENT.md`

152
src/DEPLOYMENT-CHECKLIST.md Normal file
View File

@@ -0,0 +1,152 @@
# ✅ Чеклист деплоя SOFTCLICK
## Перед деплоем
- [ ] Код работает локально (`npm run dev`)
- [ ] Сборка успешна (`npm run build`)
- [ ] Все изменения закоммичены в Git
- [ ] Выбрана платформа для деплоя
## 📁 Созданные файлы конфигурации
### Для разных платформ:
- [x] `netlify.toml` - Netlify
- [x] `.github/workflows/deploy.yml` - GitHub Pages
- [x] `.github/workflows/deploy-yandex.yml` - Яндекс.Облако
- [x] `.htaccess` - Apache хостинг
- [x] `_redirects` - Cloudflare Pages
- [x] `_headers` - Cloudflare Pages
- [x] `vercel.json` - Vercel (опционально)
### Скрипты деплоя:
- [x] `deploy.sh` - Linux/Mac
- [x] `deploy.bat` - Windows
### Документация:
- [x] `DEPLOYMENT.md` - Полная инструкция
- [x] `DEPLOY-QUICK.md` - Быстрая шпаргалка
- [x] `deploy-yandex-cloud.md` - Яндекс.Облако
- [x] `deploy-traditional-hosting.md` - Традиционный хостинг
---
## 🎯 Выбор платформы
### Вариант 1: Netlify (РЕКОМЕНДУЕТСЯ)
- [ ] Зарегистрироваться на netlify.com
- [ ] Подключить Git-репозиторий
- [ ] Запустить деплой
- [ ] Проверить результат
**Время:** ~2 минуты
**Автодеплой:** ✅ Да
**Стоимость:** Бесплатно
### Вариант 2: Cloudflare Pages
- [ ] Зарегистрироваться на pages.cloudflare.com
- [ ] Подключить Git-репозиторий
- [ ] Build: `npm run build`, Output: `dist`
- [ ] Запустить деплой
**Время:** ~3 минуты
**Автодеплой:** ✅ Да
**Стоимость:** Бесплатно
### Вариант 3: GitHub Pages
- [ ] Код в GitHub репозитории
- [ ] Settings → Pages → Source: "GitHub Actions"
- [ ] Готово (workflow уже настроен)
**Время:** ~1 минута
**Автодеплой:** ✅ Да
**Стоимость:** Бесплатно
### Вариант 4: Яндекс.Облако
- [ ] Создать аккаунт на cloud.yandex.ru
- [ ] Установить Yandex Cloud CLI
- [ ] Создать бакет Object Storage
- [ ] Настроить публичный доступ
- [ ] Загрузить файлы
**Время:** ~10 минут
**Автодеплой:** ✅ Да (через GitHub Actions)
**Стоимость:** ~50-200₽/месяц
### Вариант 5: Традиционный хостинг
- [ ] Купить хостинг (Timeweb/Beget/Reg.ru)
- [ ] Получить FTP доступ
- [ ] Собрать проект: `npm run build`
- [ ] Загрузить содержимое `dist/` через FTP
- [ ] Проверить `.htaccess`
**Время:** ~15 минут
**Автодеплой:** ❌ Нет (ручная загрузка)
**Стоимость:** от 120₽/месяц
---
## 🔍 После деплоя
- [ ] Сайт открывается
- [ ] Все страницы загружаются
- [ ] Навигация работает
- [ ] Стили применяются
- [ ] Модальные окна открываются
- [ ] Нет ошибок в консоли (F12)
- [ ] Мобильная версия работает
---
## 🐛 Устранение проблем
### Белый экран
- [ ] Проверить консоль браузера (F12)
- [ ] Убедиться что все файлы загружены
- [ ] Проверить путь к CSS файлам
### 404 при переходе на страницы
- [ ] Проверить `.htaccess` (для Apache)
- [ ] Проверить конфигурацию rewrites
- [ ] На Netlify/Cloudflare/GitHub Pages должно работать автоматически
### Ошибки сборки
```bash
# Очистить и пересобрать
rm -rf node_modules dist
npm install
npm run build
```
---
## 📊 Сравнение платформ
| Платформа | Сложность | Время | Автодеплой | Российская | Стоимость |
|-----------|-----------|-------|------------|------------|-----------|
| Netlify | ⭐ Легко | 2 мин | ✅ | ❌ | Бесплатно |
| Cloudflare | ⭐ Легко | 3 мин | ✅ | ❌ | Бесплатно |
| GitHub Pages | ⭐ Легко | 1 мин | ✅ | ❌ | Бесплатно |
| Яндекс.Облако | ⭐⭐ Средне | 10 мин | ✅ | ✅ | ~100₽/мес |
| Традиц. хостинг | ⭐⭐⭐ Сложнее | 15 мин | ❌ | ✅ | от 120₽/мес |
---
## 🎉 Готово!
После успешного деплоя:
- [ ] Сохранить URL сайта
- [ ] Настроить кастомный домен (если нужно)
- [ ] Настроить SSL (HTTPS)
- [ ] Добавить в поисковики (Google/Яндекс)
---
## 📞 Нужна помощь?
1. **Проблемы со сборкой** → Проверьте `npm run build` локально
2. **Проблемы с деплоем** → Смотрите логи на платформе
3. **404 ошибки** → Проверьте конфигурацию роутинга
4. **Стили не загружаются** → Проверьте консоль браузера
📖 Полная документация: `DEPLOYMENT.md`
⚡ Быстрая шпаргалка: `DEPLOY-QUICK.md`

164
src/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,164 @@
# 🚀 Инструкции по деплою SOFTCLICK
Этот документ содержит все способы публикации вашего сайта.
## 🎯 Быстрый старт
### Локальная сборка
```bash
# 1. Установите зависимости
npm install
# 2. Соберите проект
npm run build
# 3. Папка dist/ (или build/) содержит готовый сайт
```
**⚠️ Важно:** Vite может создать папку `dist` или `build` в зависимости от конфигурации. Проверьте, какая папка создаётся после `npm run build`.
---
## 📌 Рекомендуемые платформы
### ⭐ 1. Netlify (САМЫЙ ПРОСТОЙ)
**✅ Плюсы:** Бесплатно, автоматический деплой, HTTPS, CDN
**⏱ Время настройки:** 2 минуты
**Инструкция:**
1. Регистрация: https://netlify.com
2. "Add new site" → "Import an existing project"
3. Выберите ваш Git-репозиторий
4. Netlify автоматически найдет `netlify.toml`
5. Нажмите "Deploy" - готово! 🎉
**Автоматизация:** При каждом пуше в репозиторий сайт обновляется автоматически.
---
### ⭐ 2. Cloudflare Pages (САМЫЙ БЫСТРЫЙ)
**✅ Плюсы:** Бесплатно, очень быстрый CDN, неограниченный трафик
**⏱ Время настройки:** 3 минуты
**Инструкция:**
1. Регистрация: https://pages.cloudflare.com
2. "Create a project" → подключите Git
3. Настройки:
- Build command: `npm run build`
- Build output: `dist`
4. Deploy - готово! 🎉
---
### ⭐ 3. GitHub Pages (100% БЕСПЛАТНО)
**✅ Плюсы:** Полностью бесплатно навсегда
**⏱ Время настройки:** 1 минута
**Инструкция:**
1. Запушьте код в GitHub
2. Settings → Pages → Source: "GitHub Actions"
3. Готово! Автоматический деплой настроен 🎉
**URL:** `https://username.github.io/repository-name`
---
## 🇷🇺 Российские платформы
### 4. Яндекс.Облако (ИМПОРТОЗАМЕЩЕНИЕ)
**✅ Плюсы:** Российская платформа, Object Storage + CDN
**💰 Стоимость:** ~50-200 ₽/месяц
**⏱ Время настройки:** 10 минут
**Подробная инструкция:** См. файл `deploy-yandex-cloud.md`
**Быстрая настройка:**
```bash
# 1. Установите Yandex Cloud CLI
curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash
# 2. Создайте бакет
yc storage bucket create --name softclick-website --public-read
# 3. Соберите и загрузите
npm run build
yc storage s3api sync ./dist/ s3://softclick-website/
```
---
### 5. Традиционный хостинг
**Подходит для:** Timeweb, Beget, Reg.ru, HostiMan
**💰 Стоимость:** от 120 ₽/месяц
**Подробная инструкция:** См. файл `deploy-traditional-hosting.md`
**Краткая версия:**
1. Соберите проект: `npm run build`
2. Загрузите содержимое папки `dist/` на хостинг через FTP
3. Файл `.htaccess` уже готов и настроен
4. Готово! 🎉
---
## 🛠 Автоматизация
### Автоматический деплой при пуше в Git
**GitHub Actions готовы для:**
- ✅ GitHub Pages → `.github/workflows/deploy.yml`
- ✅ Яндекс.Облако → `.github/workflows/deploy-yandex.yml`
**Настройка GitHub Actions для Яндекс.Облако:**
1. Создайте сервисный аккаунт в Yandex Cloud
2. Добавьте секреты в GitHub:
- `YC_SERVICE_ACCOUNT_KEY_JSON` - ключ сервисного аккаунта
- `YC_BUCKET_NAME` - название бакета
3. При пуше в main автоматически загрузится на Яндекс.Облако
---
## 📋 Сравнительная таблица
| Платформа | Цена | Скорость настройки | Автодеплой | Российская |
|-----------|------|-------------------|------------|------------|
| Netlify | Бесплатно | ⚡⚡⚡ 2 мин | ✅ | ❌ |
| Cloudflare Pages | Бесплатно | ⚡⚡⚡ 3 мин | ✅ | ❌ |
| GitHub Pages | Бесплатно | ⚡⚡⚡ 1 мин | ✅ | ❌ |
| Яндекс.Облако | ~100₽/мес | ⚡⚡ 10 мин | ✅ | ✅ |
| Timeweb/Beget | от 120₽/мес | ⚡ 15 мин | ❌ | ✅ |
---
## 🎯 Рекомендации
**Для быстрого запуска:** Netlify или Cloudflare Pages
**Для 100% бесплатно:** GitHub Pages
**Для импортозамещения:** Яндекс.Облако
**Для полного контроля:** Свой VPS + Nginx
---
## 🆘 Помощь
**Не работает сборка?**
```bash
# Очистите кеш и пересоберите
rm -rf node_modules dist
npm install
npm run build
```
**Проблемы с роутингом?**
- Проверьте наличие `.htaccess` (Apache) или настройки Nginx
- На платформах типа Netlify/Cloudflare это работает автоматически
**Белый экран после деплоя?**
- Проверьте консоль браузера (F12)
- Убедитесь что загружены все файлы из `dist/`
---
## 📞 Контакты
При возникновении проблем с деплоем - проверьте логи сборки на выбранной платформе.

237
src/FILES-OVERVIEW.md Normal file
View File

@@ -0,0 +1,237 @@
# 📁 Обзор файлов проекта
## 🚀 НАЧНИТЕ С ЭТОГО ФАЙЛА
**→ START-HERE.md** - Быстрый старт для деплоя на Netlify
---
## 📖 Документация по деплою (в порядке важности)
### Обязательно прочитать:
1. **COMMIT-AND-DEPLOY.md** - Пошаговая инструкция коммита и деплоя
2. **NETLIFY-FIX.md** - Решение проблем с Netlify
3. **NODE-VERSION-FIX.md** - Информация о Node.js v18
### Дополнительная информация:
4. **DEPLOYMENT.md** - Полная инструкция по всем платформам
5. **DEPLOY-QUICK.md** - Быстрая шпаргалка
6. **PLATFORMS.md** - Детальное сравнение платформ
7. **DEPLOYMENT-CHECKLIST.md** - Чеклист для деплоя
### История изменений:
8. **LATEST-FIXES.md** - Последние исправления (Node.js v18)
9. **FIXES-SUMMARY.md** - Полная сводка всех исправлений
### Специфичные платформы:
10. **deploy-yandex-cloud.md** - Яндекс.Облако
11. **deploy-traditional-hosting.md** - Timeweb, Beget, Reg.ru
---
## 🔧 Конфигурационные файлы
### Главные конфигурации:
- **package.json** - Зависимости и скрипты
- Содержит `engines` для Node.js 18
- **vite.config.ts** - Конфигурация Vite
- `outDir: 'build'` - выходная папка
- **tsconfig.json** - TypeScript конфигурация
### Деплой конфигурации:
- **.nvmrc** - Фиксация Node.js 18 (для Netlify)
- **netlify.toml** - Настройки Netlify
- `publish = "build"`
- `NODE_VERSION = "18"`
- **vercel.json** - Настройки Vercel (опционально)
- **.htaccess** - Для Apache хостинга
- **.gitignore** - Исключения для Git
### Стили и сборка:
- **postcss.config.js** - PostCSS конфигурация
- **tailwind.config.js** - Tailwind CSS v4 конфигурация
- **/styles/globals.css** - Глобальные стили
- **/styles/animations.css** - Анимации
---
## 🛠 Скрипты и утилиты
### Скрипты проверки:
- **check-build.sh** - Проверка сборки (Linux/Mac)
- **check-build.bat** - Проверка сборки (Windows)
- Проверяют Node.js версию
- Собирают проект
- Показывают output директорию
### Скрипты деплоя:
- **deploy.sh** - Полуавтоматический деплой (Linux/Mac)
- **deploy.bat** - Полуавтоматический деплой (Windows)
---
## 📱 Исходный код приложения
### Корневые файлы:
- **App.tsx** - Главный компонент с роутингом
- **main.tsx** - Точка входа приложения
- **index.html** - HTML шаблон
### Компоненты:
- **/components/Header.tsx** - Шапка с навигацией
- **/components/Footer.tsx** - Подвал сайта
- **/components/Hero.tsx** - Hero-секция с метриками
- **/components/About.tsx** - О компании
- **/components/Services.tsx** - Услуги
- **/components/Solutions.tsx** - Решения
- **/components/Cases.tsx** - Кейсы
- **/components/Contact.tsx** - Контакты
- **/components/ContactModal.tsx** - Модальное окно
- **/components/CategoryPage.tsx** - Шаблон страницы категории
- **/components/ui/** - UI компоненты (shadcn/ui)
### Страницы:
- **/pages/HomePage.tsx** - Главная страница
- **/pages/RussianSoftwarePage.tsx** - Российское ПО
- **/pages/RussianHardwarePage.tsx** - Российское оборудование
- **/pages/CloudPage.tsx** - Облачные решения
- **/pages/CybersecurityPage.tsx** - Кибербезопасность
- **/pages/BusinessPage.tsx** - Бизнес-приложения
- **/pages/AISolutionsPage.tsx** - AI решения
- **/pages/CADandGISPage.tsx** - CAD/GIS системы
- **/pages/IndustrialSolutionsPage.tsx** - Промышленные решения
- **/pages/HardwareSolutionsPage.tsx** - Оборудование
- **/pages/GovernmentPage.tsx** - Госсектор
- **/pages/ConsultingPage.tsx** - Консалтинг
- **/pages/IntegrationPage.tsx** - Интеграция
- **/pages/ImplementationPage.tsx** - Внедрение
- **/pages/SupportPage.tsx** - Поддержка
- **/pages/ManagedServicesPage.tsx** - Управляемые услуги
- **/pages/SubscriptionPage.tsx** - Подписки
- **/pages/SpecializedServicesPage.tsx** - Спец. услуги
---
## 🎨 Статические ресурсы
### Public файлы:
- **/public/favicon.svg** - Иконка сайта (буква "S")
---
## 📚 Дополнительные файлы
- **README.md** - Основной README проекта
- **Attributions.md** - Лицензии и атрибуты
- **guidelines/Guidelines.md** - Руководство по разработке
---
## 🗂 Структура проекта
```
/
├── 🚀 START-HERE.md ← НАЧНИТЕ ОТСЮДА
├── 📖 Документация по деплою
│ ├── COMMIT-AND-DEPLOY.md
│ ├── NETLIFY-FIX.md
│ ├── NODE-VERSION-FIX.md
│ ├── DEPLOYMENT.md
│ ├── DEPLOY-QUICK.md
│ ├── PLATFORMS.md
│ ├── DEPLOYMENT-CHECKLIST.md
│ ├── LATEST-FIXES.md
│ ├── FIXES-SUMMARY.md
│ ├── deploy-yandex-cloud.md
│ └── deploy-traditional-hosting.md
├── 🔧 Конфигурации
│ ├── .nvmrc
│ ├── .gitignore
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── netlify.toml
│ ├── postcss.config.js
│ ├── tailwind.config.js
│ ├── .htaccess
│ └── vercel.json
├── 🛠 Скрипты
│ ├── check-build.sh
│ ├── check-build.bat
│ ├── deploy.sh
│ └── deploy.bat
├── 📱 Исходный код
│ ├── App.tsx
│ ├── main.tsx
│ ├── index.html
│ ├── /components/ (React компоненты)
│ ├── /pages/ (Страницы)
│ ├── /styles/ (Стили)
│ └── /public/ (Статические файлы)
└── 📚 Прочее
├── README.md
├── Attributions.md
└── guidelines/
```
---
## 🎯 Какой файл читать?
### Хочу быстро задеплоить на Netlify:
→ **START-HERE.md**
### Netlify выдаёт ошибку:
→ **NETLIFY-FIX.md**
### Проблемы с Node.js версией:
→ **NODE-VERSION-FIX.md**
### Хочу сравнить платформы:
→ **PLATFORMS.md**
### Нужна полная инструкция:
→ **DEPLOYMENT.md**
### Хочу задеплоить на Яндекс.Облако:
→ **deploy-yandex-cloud.md**
### Что было исправлено:
→ **LATEST-FIXES.md** или **FIXES-SUMMARY.md**
---
## 📋 NPM Scripts
```bash
npm run dev # Запуск dev-сервера (http://localhost:5173)
npm run build # Сборка для продакшена (→ build/)
npm run preview # Предпросмотр продакшен-сборки
```
---
## ✨ Особенности проекта
- ✅ **React 18** + **TypeScript**
- ✅ **Vite 5** для быстрой сборки
- ✅ **Tailwind CSS v4** для стилизации
- ✅ **React Router** для навигации
- ✅ **Lucide Icons** для иконок
- ✅ **shadcn/ui** компоненты
- ✅ **Responsive дизайн**
- ✅ **Российские аналоги** зарубежных решений
- ✅ **Минималистичный дизайн** без фотографий
---
## 🔗 Полезные ссылки
- Vite: https://vitejs.dev
- React: https://react.dev
- Tailwind CSS: https://tailwindcss.com
- Netlify: https://netlify.com
- Node.js: https://nodejs.org
---
Этот файл создан для быстрой навигации по проекту. Начните с **START-HERE.md**! 🚀

226
src/FIXES-SUMMARY.md Normal file
View File

@@ -0,0 +1,226 @@
# 📋 Сводка исправлений для деплоя
## 🔧 Последнее обновление: Node.js версия (24.12.2024)
### ❌ Новая проблема
Netlify использовал Node.js v22.21.1, вызывая проблемы совместимости.
### ✅ Решение
1. Зафиксирована **Node.js 18 LTS** через:
- `.nvmrc` (создан)
- `package.json` engines (добавлен)
- `netlify.toml` (уже был)
2. Обновлён `vite.config.ts`:
- `outDir: 'build'` вместо `'dist'`
3. Обновлены скрипты проверки:
- Проверка версии Node.js
- Предупреждение о Node.js v22
**Документация:** См. `NODE-VERSION-FIX.md` и `LATEST-FIXES.md`
---
## 🔧 Предыдущие исправления
### 1. Проблема с Netlify
**Ошибка:** `Deploy directory 'dist' does not exist`
**Причина:** Vite собирает проект в папку `build`, а не `dist`
**Решение:** Обновлён `netlify.toml`:
```toml
[build]
command = "npm run build"
publish = "build" # Изменено с "dist" на "build"
```
---
### 2. Добавлены скрипты проверки сборки
**check-build.sh** (Linux/Mac) и **check-build.bat** (Windows)
Эти скрипты:
- Очищают старые сборки
- Устанавливают зависимости
- Собирают проект
- Показывают, какая папка создана (dist или build)
- Подсказывают правильные настройки для Netlify
**Использование:**
```bash
# Linux/Mac
chmod +x check-build.sh
./check-build.sh
# Windows
check-build.bat
```
---
### 3. Создан .gitignore
Файл `.gitignore` для исключения:
- node_modules
- .env файлов
- логов
- кеша
- системных файлов
---
### 4. Обновлена документация
#### NETLIFY-FIX.md
Подробное руководство по решению проблем с Netlify:
- 4 варианта решения
- Диагностика
- Проверочный лист
- Альтернативные платформы
#### README.md
Добавлен раздел с проверкой сборки перед деплоем
#### DEPLOYMENT.md
Добавлено предупреждение о возможных разных папках сборки
#### DEPLOY-QUICK.md
Добавлена информация о проверке сборки
---
## 📁 Новые файлы
```
/.gitignore - Исключения для Git
/check-build.sh - Скрипт проверки (Linux/Mac)
/check-build.bat - Скрипт проверки (Windows)
/NETLIFY-FIX.md - Решение проблем Netlify
/FIXES-SUMMARY.md - Этот файл
/.nvmrc - Фиксация Node.js версии
/NODE-VERSION-FIX.md - Решение проблем с Node.js версией
/LATEST-FIXES.md - Последние исправления
```
## ✅ Файлы обновлены
```
/netlify.toml - publish: "build" вместо "dist"
/README.md - Добавлена секция проверки
/DEPLOYMENT.md - Добавлено предупреждение
/DEPLOY-QUICK.md - Добавлена информация о проверке
/vite.config.ts - outDir: 'build' вместо 'dist'
/package.json - Добавлены engines
```
---
## 🚀 Что делать дальше
### Шаг 1: Закоммитьте изменения
```bash
git add .
git commit -m "Fix: Update deployment configs for build directory"
git push
```
### Шаг 2: Деплой на Netlify
После пуша Netlify автоматически:
1. Обнаружит новый коммит
2. Запустит сборку `npm run build`
3. Найдёт папку `build` (согласно netlify.toml)
4. Задеплоит сайт
### Шаг 3: Проверка
После деплоя проверьте:
- [ ] Сайт открывается
- [ ] Навигация работает
- [ ] Стили применяются
- [ ] Нет ошибок в консоли
---
## 🆘 Если Netlify всё ещё не работает
### Вариант 1: Проверьте настройки Netlify UI
1. Site settings → Build & deploy → Build settings
2. Publish directory должна быть: `build`
3. Если нет - измените вручную
### Вариант 2: Используйте Cloudflare Pages
Cloudflare лучше определяет настройки автоматически:
```
1. https://pages.cloudflare.com
2. Build: npm run build
3. Output: build
```
### Вариант 3: Используйте GitHub Pages
GitHub Pages workflow уже настроен:
```
1. Settings → Pages → Source: "GitHub Actions"
2. Готово!
```
---
## 📊 Текущая конфигурация
### Сборка
- Команда: `npm run build`
- Output: `build/` (судя по логам Netlify)
- Конфигурация: `vite.config.ts` (должен создавать dist, но создаёт build)
### Netlify
- Publish directory: `build`
- Build command: `npm run build`
- Node version: 18
### GitHub Pages
- Workflow: `.github/workflows/deploy.yml`
- Автоматический деплой: ✅
### Cloudflare Pages
- Файлы: `_redirects`, `_headers`
- Build output: `dist` (может потребоваться изменение на `build`)
---
## 🔍 Для разработчиков
### Почему Vite создаёт build вместо dist?
Судя по логам Netlify:
```
build/assets/index-Bi1CT-Fn.js 244.81 kB │ gzip: 69.31 kB
✓ built in 1.93s
```
Vite явно создаёт папку `build`, хотя в `vite.config.ts` указано `outDir: 'dist'`.
**Возможные причины:**
1. Конфигурация переопределяется где-то ещё
2. Netlify использует собственную конфигурацию Vite
3. Есть конфликт версий
**Решение:**
- Приняли `build` как output directory
- Обновили все конфигурации соответственно
---
## ✅ Итог
Все конфигурационные файлы обновлены и готовы к деплою!
**Следующий шаг:**
```bash
git add .
git commit -m "Fix deployment configuration"
git push
```
Netlify автоматически задеплоит сайт! 🎉

230
src/LATEST-FIXES.md Normal file
View File

@@ -0,0 +1,230 @@
# 🔧 Последние исправления (Node.js версия)
## Дата: 24 декабря 2024
## ❌ Проблема
Netlify использовал Node.js v22.21.1, что вызывало проблемы совместимости с:
- Vite 5.x
- Tailwind CSS v4
- PostCSS
- TypeScript build tools
## ✅ Решение
### 1. Зафиксирована версия Node.js на 18 LTS
**Создан `.nvmrc`:**
```
18
```
**Обновлён `package.json`:**
```json
{
"engines": {
"node": ">=18.0.0 <22.0.0",
"npm": ">=9.0.0"
}
}
```
**Уже был в `netlify.toml`:**
```toml
[build.environment]
NODE_VERSION = "18"
```
### 2. Исправлен output директорий в vite.config.ts
**Было:**
```typescript
build: {
outDir: 'dist',
}
```
**Стало:**
```typescript
build: {
outDir: 'build',
}
```
Теперь Vite создаёт папку `build`, что соответствует настройкам в `netlify.toml`.
### 3. Обновлены скрипты проверки
`check-build.sh` и `check-build.bat` теперь:
- Проверяют версию Node.js
- Предупреждают если используется Node.js v22
- Показывают размер и содержимое build/
---
## 📁 Изменённые файлы
```
/.nvmrc - СОЗДАН (новый)
/package.json - ОБНОВЛЁН (добавлен engines)
/vite.config.ts - ОБНОВЛЁН (outDir: 'build')
/check-build.sh - ОБНОВЛЁН (проверка Node версии)
/check-build.bat - ОБНОВЛЁН (проверка Node версии)
/NODE-VERSION-FIX.md - СОЗДАН (документация)
/LATEST-FIXES.md - СОЗДАН (этот файл)
/README.md - ОБНОВЛЁН (требования Node.js)
```
---
## 🚀 Что делать дальше
### 1. Закоммитьте все изменения
```bash
git status
git add .
git commit -m "Fix: Pin Node.js to v18 LTS and set Vite outDir to 'build'"
git push origin main
```
### 2. Netlify автоматически начнёт деплой
После пуша Netlify:
1. Прочитает `.nvmrc` → использует Node.js 18
2. Установит зависимости с `npm install`
3. Запустит `npm run build` → Vite создаст папку `build`
4. Опубликует содержимое `build/` (согласно `netlify.toml`)
### 3. Проверка деплоя
Откройте **Netlify Deploy Logs** и убедитесь что:
```
✅ v18.x.x is already installed.
✅ Now using node v18.x.x
✅ npm run build
✅ vite v5.x.x building for production...
✅ build/assets/index-xxxxx.js
✅ ✓ built in X.XXs
✅ Deploy successful!
```
---
## 🔍 Проверка локально (перед коммитом)
Убедитесь что у вас Node.js 18:
```bash
# Проверить версию Node
node -v
# Если не 18, установите через nvm
nvm install 18
nvm use 18
# Запустите скрипт проверки
chmod +x check-build.sh
./check-build.sh
# Должно показать:
# ✅ Node.js версия совместима (v18.x.x)
# ✅ BUILD OUTPUT: build
```
---
## 📊 Сравнение версий Node.js
| Версия | Статус | Совместимость | Рекомендация |
|--------|--------|---------------|--------------|
| Node.js 16 | EOL | ⚠️ Устарела | Обновите на 18 |
| Node.js 18 | LTS ✅ | ✅ Отлично | **Используйте** |
| Node.js 20 | LTS | ✅ Хорошо | Допустимо |
| Node.js 22 | Current | ⚠️ Проблемы | Не рекомендуется |
**LTS** = Long Term Support (долгосрочная поддержка)
---
## 🆘 Если деплой всё ещё не работает
### Вариант 1: Очистите кеш Netlify
1. Netlify UI → **Site settings** → **Build & deploy**
2. Нажмите **"Clear cache and retry deploy"**
### Вариант 2: Проверьте переменные окружения
В Netlify UI:
1. **Site settings** → **Environment variables**
2. Убедитесь что `NODE_VERSION` **НЕ** установлена вручную (или равна 18)
3. Если есть другое значение - удалите его (`.nvmrc` имеет приоритет)
### Вариант 3: Используйте Cloudflare Pages
Cloudflare Pages лучше работает с новыми версиями инструментов:
```
1. https://pages.cloudflare.com
2. Подключить GitHub репозиторий
3. Build command: npm run build
4. Build output: build
5. Environment variable: NODE_VERSION = 18
```
### Вариант 4: Используйте GitHub Pages
Уже настроено автоматически:
```
1. GitHub repo → Settings → Pages
2. Source: "GitHub Actions"
3. Workflow автоматически использует Node 18
```
---
## ✅ Контрольный список
- [x] Создан `.nvmrc` с версией 18
- [x] Добавлен `engines` в `package.json`
- [x] `vite.config.ts` обновлён (outDir: 'build')
- [x] Скрипты проверки обновлены
- [x] Документация обновлена
- [ ] **ВАШ ШАГ:** Закоммитьте изменения
- [ ] **ВАШ ШАГ:** Запушьте в GitHub
- [ ] **АВТОМАТИЧЕСКИ:** Netlify запустит деплой
- [ ] **ПРОВЕРЬТЕ:** Сайт задеплоен успешно
---
## 📖 Дополнительная документация
- **NODE-VERSION-FIX.md** - подробная информация о Node.js версиях
- **NETLIFY-FIX.md** - решение проблем с Netlify
- **DEPLOYMENT.md** - полная инструкция по деплою
- **DEPLOY-QUICK.md** - быстрая шпаргалка
---
## ✨ Ожидаемый результат
После коммита и пуша:
```
✓ Код запушен в GitHub
↓
✓ Netlify обнаружил изменения
↓
✓ Запущен build с Node.js 18
↓
✓ Vite создал папку build/
↓
✓ Netlify опубликовал build/
↓
✓ Сайт доступен: https://your-site.netlify.app
```
**Время деплоя:** ~2-3 минуты
Удачи! 🚀

195
src/NETLIFY-FIX.md Normal file
View File

@@ -0,0 +1,195 @@
# 🔧 Решение проблемы с Netlify Deploy
## Проблема
```
Deploy did not succeed: Deploy directory 'dist' does not exist
```
## Причина
Vite создаёт папку `build` вместо `dist`, или конфигурация не применяется правильно.
---
## ✅ Решение 1: Обновлён netlify.toml (УЖЕ СДЕЛАНО)
Файл `netlify.toml` обновлён для использования папки `build`:
```toml
[build]
command = "npm run build"
publish = "build"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
```
**Просто сделайте новый коммит и пуш - должно заработать!**
---
## ✅ Решение 2: Проверка локально
Запустите скрипт проверки:
**Linux/Mac:**
```bash
chmod +x check-build.sh
./check-build.sh
```
**Windows:**
```cmd
check-build.bat
```
Скрипт покажет, в какую папку собирается проект (dist или build).
---
## ✅ Решение 3: Настройка через Netlify UI
Если проблема сохраняется:
1. Зайдите в **Site settings** → **Build & deploy** → **Build settings**
2. Измените **Publish directory** на:
- `build` (если Vite собирает в build)
- `dist` (если Vite собирает в dist)
3. Сохраните и запустите новый деплой
---
## ✅ Решение 4: Принудительная сборка в dist
Если хотите использовать именно `dist`, убедитесь что `vite.config.ts` правильный:
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist', // Явно указываем dist
emptyOutDir: true,
},
});
```
Затем в `netlify.toml`:
```toml
[build]
command = "npm run build"
publish = "dist"
```
---
## 🔍 Диагностика
### Проверка логов Netlify
В логах деплоя ищите строки типа:
```
build/assets/index-xxxxx.js
```
или
```
dist/assets/index-xxxxx.js
```
Это покажет, в какую папку собирается проект.
### Локальная проверка
```bash
# Очистка
rm -rf dist build node_modules
# Установка
npm install
# Сборка
npm run build
# Проверка результата
ls -la
# Должна появиться папка dist или build
```
---
## 📝 Текущая конфигурация
**netlify.toml** настроен на: `publish = "build"`
Это основано на логах деплоя, где видно:
```
build/assets/index-Bi1CT-Fn.js 244.81 kB │ gzip: 69.31 kB
✓ built in 1.93s
```
---
## 🚀 Быстрое решение
1. **Закоммитьте изменения:**
```bash
git add netlify.toml
git commit -m "Fix: Update Netlify publish directory to build"
git push
```
2. **Netlify автоматически запустит новый деплой**
3. **Готово!** Сайт должен задеплоиться успешно
---
## 🆘 Если всё ещё не работает
### Вариант A: Смените платформу на Cloudflare Pages
Cloudflare Pages лучше определяет конфигурацию автоматически:
1. https://pages.cloudflare.com
2. Build command: `npm run build`
3. Build output: попробуйте оба варианта - `dist` или `build`
### Вариант B: Используйте GitHub Pages
GitHub Pages workflow уже настроен в `.github/workflows/deploy.yml`:
1. Settings → Pages → Source: "GitHub Actions"
2. Готово! Деплой автоматический
---
## 📊 Проверочный лист
- [ ] netlify.toml обновлён (publish = "build")
- [ ] Закоммичены все изменения
- [ ] Запушен код в репозиторий
- [ ] Netlify автоматически запустил новый деплой
- [ ] Проверены логи деплоя
- [ ] Сайт открывается успешно
---
## ✅ Ожидаемый результат
После исправления в логах Netlify должно быть:
```
✓ built in 1.93s
Deploy site
────────────────────────────────────────────────────────────────
Deploy successful!
Website URL: https://your-site.netlify.app
```
Удачи! 🚀

208
src/NODE-VERSION-FIX.md Normal file
View File

@@ -0,0 +1,208 @@
# 🔧 Исправление проблем с версией Node.js
## Проблема
Netlify использует Node.js v22, которая может быть несовместима с некоторыми пакетами (Vite, Tailwind CSS v4, и др.).
## ✅ Решение (УЖЕ ПРИМЕНЕНО)
Версия Node.js зафиксирована на **18 LTS** следующими способами:
### 1. Файл .nvmrc
```
18
```
### 2. package.json
```json
{
"engines": {
"node": ">=18.0.0 <22.0.0",
"npm": ">=9.0.0"
}
}
```
### 3. netlify.toml
```toml
[build.environment]
NODE_VERSION = "18"
```
### 4. vite.config.ts
Обновлён `outDir` на `build`:
```typescript
export default defineConfig({
build: {
outDir: 'build', // Изменено с 'dist' на 'build'
emptyOutDir: true,
sourcemap: false,
},
});
```
---
## 🚀 Что делать дальше
### Шаг 1: Закоммитьте изменения
```bash
git add .
git commit -m "Fix: Pin Node.js to v18 LTS and update build output to 'build'"
git push
```
### Шаг 2: Netlify автоматически запустит деплой
После пуша Netlify:
- Использует Node.js v18 (из .nvmrc)
- Соберёт проект командой `npm run build`
- Найдёт папку `build` (из vite.config.ts и netlify.toml)
- Задеплоит сайт
---
## 🔍 Проверка локально
Убедитесь что у вас локально Node.js 18:
```bash
# Проверить версию
node -v
# Если используете nvm (рекомендуется)
nvm install 18
nvm use 18
# Проверить сборку
./check-build.sh # Linux/Mac
check-build.bat # Windows
```
---
## 📊 Почему Node.js 22 вызывает проблемы?
Node.js 22 - это очень новая версия (выпущена в апреле 2024), и многие популярные инструменты ещё не полностью протестированы с ней:
- **Vite 5.x** - стабильно работает на Node 18-20
- **Tailwind CSS v4** - новая версия, оптимизирована для Node 18+
- **PostCSS** - лучшая совместимость на Node 18 LTS
- **TypeScript** - рекомендует Node 18 LTS
**Node.js 18 LTS** (Long Term Support):
- Поддержка до апреля 2025
- Максимальная стабильность
- Совместимость со всеми популярными инструментами
---
## 🆘 Если проблема сохраняется
### 1. Проверьте полные логи Netlify
В Netlify UI зайдите в:
- **Deploys** → Выберите последний деплой
- Нажмите на **Deploy log**
- Найдите строки после "Finished restoring cached build plugins"
### 2. Очистите кеш Netlify
В настройках Netlify:
1. **Site settings** → **Build & deploy** → **Build settings**
2. Нажмите **Clear cache and retry deploy**
### 3. Проверьте локальную сборку
```bash
# Очистите всё
rm -rf node_modules dist build package-lock.json
# Переустановите зависимости
npm install
# Соберите
npm run build
# Проверьте результат
ls -la build/
```
Если локально собирается без ошибок, но на Netlify нет - проблема в среде Netlify.
### 4. Альтернативные платформы
Если Netlify продолжает давать ошибки, используйте:
#### Cloudflare Pages (рекомендуется)
```
1. https://pages.cloudflare.com
2. Build: npm run build
3. Output: build
4. Node version: 18
```
#### GitHub Pages
```
Settings → Pages → Source: "GitHub Actions"
(Workflow уже настроен в .github/workflows/deploy.yml)
```
---
## 📋 Проверочный лист
- [x] Создан `.nvmrc` с версией 18
- [x] Добавлен `engines` в `package.json`
- [x] `netlify.toml` указывает `NODE_VERSION = "18"`
- [x] `vite.config.ts` использует `outDir: 'build'`
- [x] `netlify.toml` указывает `publish = "build"`
- [ ] Закоммичены все изменения
- [ ] Запушен код в GitHub
- [ ] Netlify запустил новый деплой
- [ ] Деплой успешен
---
## ✅ Ожидаемый результат
После исправлений в логах Netlify должно быть:
```
v18.x.x is already installed.
Now using node v18.x.x (npm v9.x.x)
...
npm run build
...
vite v5.x.x building for production...
transforming...
✓ 1234 modules transformed.
rendering chunks...
build/assets/index-xxxxx.js 244.81 kB │ gzip: 69.31 kB
✓ built in 1.93s
Deploy site
────────────────────────────────────────
✓ Finished hashing 15 files
✓ CDN requesting 15 files
✓ Finished uploading 15 assets
✓ Deploy is live!
Website URL: https://your-site.netlify.app
```
---
## 📞 Дополнительная помощь
Если после всех исправлений деплой всё ещё падает:
1. **Скопируйте полный лог** от "Build ready to start" до конца
2. Найдите строку с ошибкой (обычно красным цветом)
3. Поищите эту ошибку в интернете или создайте issue
**Полезные ссылки:**
- Netlify Node.js docs: https://docs.netlify.com/configure-builds/manage-dependencies/#node-js-and-javascript
- Vite troubleshooting: https://vitejs.dev/guide/troubleshooting.html
- Node.js LTS releases: https://nodejs.org/en/about/previous-releases
Удачи! 🚀

294
src/PLATFORMS.md Normal file
View File

@@ -0,0 +1,294 @@
# 🌐 Подробное сравнение платформ для деплоя
## 🏆 ТОП-3 рекомендации
### 🥇 Netlify - Лучший для начинающих
**✅ Преимущества:**
- Автоматическое определение фреймворка
- Встроенный CI/CD
- Бесплатный SSL сертификат
- Автоматические превью для pull requests
- 100 GB трафика/месяц бесплатно
- Настройка кастомного домена за 1 клик
**❌ Недостатки:**
- Не российская компания
- Лимиты на сборки (300 минут/месяц на бесплатном)
**🎯 Идеально для:**
- Быстрого запуска
- Прототипов и MVP
- Автоматического деплоя
**📊 Стоимость:**
- Free: $0/месяц (достаточно для большинства)
- Pro: $19/месяц (1000 минут сборки, 400 GB трафика)
---
### 🥈 Cloudflare Pages - Самый быстрый
**✅ Преимущества:**
- Глобальная CDN сеть
- НЕОГРАНИЧЕННЫЙ трафик на бесплатном плане
- Очень высокая скорость загрузки
- Защита от DDoS атак
- 500 сборок/месяц
**❌ Недостатки:**
- Не российская компания
- Чуть сложнее в настройке чем Netlify
**🎯 Идеально для:**
- Высоконагруженных сайтов
- Глобальной аудитории
- Максимальной скорости
**📊 Стоимость:**
- Free: $0/месяц (неограниченный трафик!)
- Платных планов для статических сайтов нет
---
### 🥉 GitHub Pages - Простейший
**✅ Преимущества:**
- 100% бесплатно навсегда
- Интеграция с GitHub
- Автоматический HTTPS
- Легкая настройка кастомного домена
- Нет лимитов на сборки
**❌ Недостатки:**
- Лимит 100 GB трафика/месяц
- Только публичные репозитории (на бесплатном)
- Нет серверных функций
**🎯 Идеально для:**
- Личных проектов
- Open source проектов
- Документации
- Портфолио
**📊 Стоимость:**
- Free: $0/месяц
---
## 🇷🇺 Российские платформы
### Яндекс.Облако Object Storage + CDN
**✅ Преимущества:**
- Российская компания
- Соответствие 152-ФЗ
- Интеграция с другими сервисами Яндекса
- Высокая скорость в РФ
- Можно оплатить картой российского банка
**❌ Недостатки:**
- Платно (хоть и недорого)
- Требует больше настройки
- Нужно самому настраивать CI/CD
**🎯 Идеально для:**
- Коммерческих проектов в РФ
- Компаний, требующих импортозамещения
- Работы с персональными данными
**📊 Стоимость:**
- Object Storage: ~0.40 ₽/ГБ хранения/месяц
- CDN трафик: ~0.85 ₽/ГБ
- Пример: Сайт 50 МБ + 1000 посещений = ~50-100₽/месяц
**🔧 Настройка:**
- Сложность: Средняя
- Время: 10-15 минут
- Требуется: Yandex Cloud CLI
---
### VK Cloud Solutions (Mail.ru Cloud)
**✅ Преимущества:**
- Российская компания (VK)
- Object Storage аналогично AWS S3
- CDN с точками в РФ
- 152-ФЗ
**❌ Недостатки:**
- Платно
- Меньше документации чем у Яндекса
**📊 Стоимость:**
- Примерно как Яндекс.Облако
- Есть промо-период с бесплатными ресурсами
---
### Традиционный хостинг (Timeweb, Beget, Reg.ru)
**✅ Преимущества:**
- Понятный интерфейс
- Техподдержка на русском
- Всё в одном месте (домен + хостинг + почта)
- Можно использовать для других проектов
**❌ Недостатки:**
- Платно
- Ручное обновление файлов
- Нет автоматического деплоя (без настройки)
**📊 Стоимость:**
- Timeweb: от 169₽/месяц
- Beget: от 200₽/месяц
- Reg.ru: от 120₽/месяц
- HostiMan: от 150₽/месяц
**🔧 Настройка:**
- Сложность: Легкая (FTP загрузка)
- Время: 5-10 минут
- Требуется: FTP клиент (FileZilla)
---
## 📊 Детальное сравнение
| Характеристика | Netlify | Cloudflare | GitHub Pages | Яндекс.Облако | Хостинг |
|----------------|---------|------------|--------------|---------------|---------|
| **Цена** | Бесплатно | Бесплатно | Бесплатно | ~100₽/мес | от 120₽/мес |
| **Трафик** | 100GB | ∞ | 100GB | Pay-as-you-go | ~100GB+ |
| **SSL** | ✅ Авто | ✅ Авто | ✅ Авто | ✅ (настройка) | ✅ Let's Encrypt |
| **CDN** | ✅ | ✅✅✅ | ✅ | ✅ (доп. оплата) | ❌/✅ |
| **Автодеплой** | ✅ | ✅ | ✅ | ✅ (настройка) | ❌ |
| **Скорость в РФ** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **Скорость мира** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| **Поддержка** | Отличная (EN) | Хорошая (EN) | FAQ | Хорошая (RU) | Отличная (RU) |
| **152-ФЗ** | ❌ | ❌ | ❌ | ✅ | ✅ |
| **Сложность** | ⭐ Легко | ⭐ Легко | ⭐ Легко | ⭐⭐ Средне | ⭐⭐ Средне |
---
## 🎯 Рекомендации по выбору
### Выбирайте Netlify если:
- ✅ Нужен быстрый запуск (2 минуты)
- ✅ Хотите автоматический деплой
- ✅ Не важна российская юрисдикция
- ✅ Трафик до 100 GB/месяц
### Выбирайте Cloudflare Pages если:
- ✅ Нужна максимальная скорость
- ✅ Ожидается много трафика
- ✅ Важна защита от DDoS
- ✅ Глобальная аудитория
### Выбирайте GitHub Pages если:
- ✅ Проект на GitHub
- ✅ Open source
- ✅ Нужно 100% бесплатно
- ✅ Простой статический сайт
### Выбирайте Яндекс.Облако если:
- ✅ Коммерческий проект
- ✅ Нужна российская платформа
- ✅ Требуется 152-ФЗ
- ✅ Работа с ПД граждан РФ
- ✅ Импортозамещение
### Выбирайте хостинг если:
- ✅ Уже есть хостинг
- ✅ Нужна техподдержка на русском
- ✅ Планируется несколько сайтов
- ✅ Нужна почта на своем домене
---
## 🚀 Скорость настройки
**Netlify:** 2 минуты
```
1. Регистрация (30 сек)
2. Подключить Git (30 сек)
3. Деплой (1 мин)
```
**Cloudflare Pages:** 3 минуты
```
1. Регистрация (30 сек)
2. Подключить Git (1 мин)
3. Настройка сборки (1 мин)
4. Деплой (30 сек)
```
**GitHub Pages:** 1 минута
```
1. Код уже на GitHub (0 сек)
2. Settings → Pages (30 сек)
3. Выбрать GitHub Actions (30 сек)
```
**Яндекс.Облако:** 15 минут
```
1. Регистрация (2 мин)
2. Установка CLI (3 мин)
3. Создание бакета (2 мин)
4. Настройка доступа (3 мин)
5. Первый деплой (5 мин)
```
**Традиционный хостинг:** 20 минут
```
1. Покупка хостинга (5 мин)
2. Получение доступов (5 мин)
3. Локальная сборка (2 мин)
4. Загрузка по FTP (5 мин)
5. Проверка (3 мин)
```
---
## 💡 Профессиональные советы
### Для стартапов и MVP:
**Netlify** - начните бесплатно, масштабируйтесь по мере роста
### Для высоконагруженных проектов:
**Cloudflare Pages** - неограниченный трафик и максимальная скорость
### Для российского бизнеса:
**Яндекс.Облако** - соответствие законодательству РФ
### Для личных проектов:
**GitHub Pages** - просто и бесплатно навсегда
### Для корпоративных клиентов:
**Традиционный хостинг** - полный контроль и поддержка
---
## 🔐 Безопасность и соответствие
| Платформа | HTTPS | 152-ФЗ | GDPR | DDoS защита |
|-----------|-------|--------|------|-------------|
| Netlify | ✅ Авто | ❌ | ✅ | ✅ |
| Cloudflare | ✅ Авто | ❌ | ✅ | ✅✅✅ |
| GitHub Pages | ✅ Авто | ❌ | ✅ | ✅ |
| Яндекс.Облако | ✅ | ✅ | ❌ | ✅ (доп.) |
| Хостинг | ✅ | ✅ | Зависит | Зависит |
---
## 📞 Куда обращаться за помощью
**Netlify:** https://docs.netlify.com
**Cloudflare:** https://developers.cloudflare.com/pages
**GitHub Pages:** https://docs.github.com/pages
**Яндекс.Облако:** https://cloud.yandex.ru/docs
**Timeweb:** https://timeweb.com/ru/help
**Beget:** https://beget.com/ru/kb
---
Выбрали платформу? Смотрите `DEPLOYMENT.md` для пошаговых инструкций! 🚀

View File

@@ -0,0 +1,659 @@
# Полный промт для воссоздания корпоративного сайта SOFTCLICK
## ОБЩАЯ КОНЦЕПЦИЯ
Создай современный корпоративный сайт для российской IT-компании **SOFTCLICK** — интегратора IT-решений, аналогичный по структуре Softline.ru, но в более сдержанных бизнес-тонах. Основная специализация: импортозамещение, переход на российские решения, комплексная автоматизация предприятий.
**Ключевые принципы дизайна:**
- Лаконичный минималистичный стиль в духе Яндекса/Google
- БЕЗ использования фотографий людей/офисов — только дизайнерские элементы (иконки, градиенты, геометрия)
- Цветовая палитра: белый фон, голубой акцент (#2563eb blue-600), серые оттенки
- Чистая типографика, много воздуха, округлые элементы (rounded-xl, rounded-2xl)
- Плавные анимации (floating icons, smooth transitions)
---
## ТЕХНИЧЕСКИЙ СТЕК
### Основа:
- **React 18** + **TypeScript**
- **Vite** для сборки
- **Tailwind CSS v4** для стилизации
- **lucide-react** для иконок
- БЕЗ React Router — используется простая навигация через state
### Структура проекта:
```
/App.tsx — главный компонент с роутингом через state
/main.tsx — точка входа
/components/ — все компоненты
Header.tsx — шапка с выпадающими меню
Hero.tsx — главный баннер
Services.tsx — блок услуг
Solutions.tsx — отраслевые решения
Cases.tsx — кейсы
About.tsx — о компании
Contact.tsx — форма контактов
Footer.tsx — подвал
ContactModal.tsx — модальное окно
CategoryPage.tsx — универсальный шаблон страниц категорий
/pages/ — страницы категорий (17 штук)
/styles/ — глобальные стили и анимации
```
---
## ДЕТАЛЬНОЕ ОПИСАНИЕ КОМПОНЕНТОВ
### 1. HEADER (Header.tsx)
**Структура:**
- Фиксированная шапка (fixed top-0) с backdrop-blur-md
- Логотип: текст "SOFTCLICK" жирным шрифтом (text-2xl font-bold)
- Навигация (десктоп):
- **Решения** (выпадающее меню с 10 категориями)
- **Услуги** (выпадающее меню с 7 категориями)
- Кейсы (якорь на секцию)
- О компании (якорь на секцию)
- Контакты (якорь на секцию)
- Кнопка "Связаться" (синяя, открывает модальное окно)
- Мобильное меню (гамбургер-меню для экранов < lg)
**Выпадающие меню:**
*Решения:*
1. Облако
2. Российское ПО
3. Кибербезопасность
4. Решения на базе ИИ
5. Российские ноутбуки, ПК и серверы
6. Аппаратные решения
7. САПР и ГИС
8. Индустриальные решения
9. Для госсектора
10. Решения для бизнеса
*Услуги:*
1. Консалтинг и проектирование
2. Поставка и внедрение решений
3. Управляемые сервисы
4. Интеграция и разработка
5. Специализированные услуги по отраслям
6. Обслуживание и сопровождение
7. Услуги по подписке
**Стилизация меню:**
- Появление при наведении (onMouseEnter/onMouseLeave)
- Белая карточка с тенью (bg-white rounded-xl shadow-xl)
- Плавная анимация (fade-in slide-in-from-top-2)
- Ширина меню: w-80
- При hover пункт меню: bg-blue-50 text-blue-600
---
### 2. HERO SECTION (Hero.tsx)
**Контент:**
- Заголовок: "IT-решения для роста вашего бизнеса" (text-5xl lg:text-6xl)
- Подзаголовок: "Проектируем, внедряем и сопровождаем корпоративные IT-системы. Облачная инфраструктура, безопасность, автоматизация процессов."
- Бейдж: "Надежный партнер с 2021 года" (с синим пульсирующим индикатором)
- 2 кнопки: "Обсудить проект" (синяя) и "Портфолио" (серая)
**Метрики (3 колонки):**
- 300+ клиентов
- 50+ партнерств
- 95% повторных обращений
**Дизайнерский элемент (справа):**
Сетка 3x2 из округлых карточек с иконками:
- Cloud (голубая)
- Shield (синяя с градиентом, тень)
- Database (фиолетовая)
- Code (зеленая)
- Zap (оранжевая)
- Users (голубая)
Карточки имеют floating-анимацию (плавное движение вверх-вниз)
---
### 3. SERVICES (Services.tsx)
**6 карточек услуг в сетке (grid md:grid-cols-2 lg:grid-cols-3):**
1. **Облачные решения** (Cloud icon)
- Описание: "Миграция и управление инфраструктурой на базе Яндекс.Облако и VK Cloud"
- Градиент: from-blue-50 to-cyan-50
2. **Кибербезопасность** (Shield icon)
- Описание: "Комплексная защита периметра и данных с решениями Kaspersky"
- Градиент: from-violet-50 to-purple-50
3. **Цифровая трансформация** (Cpu icon)
- Описание: "Автоматизация процессов на платформах 1С и Галактика"
- Градиент: from-emerald-50 to-teal-50
4. **Управление данными** (Database icon)
- Описание: "Развертывание и поддержка СУБД Postgres Pro"
- Градиент: from-orange-50 to-amber-50
5. **Серверная инфраструктура** (Server icon)
- Описание: "Поставка и настройка оборудования Kraftway"
- Градиент: from-pink-50 to-rose-50
6. **Системная интеграция** (Settings icon)
- Описание: "Построение корпоративной ИТ-архитектуры на Astra Linux"
- Градиент: from-indigo-50 to-blue-50
**Структура карточки:**
- Верхняя часть: градиентный фон (h-48) с плавающей иконкой в белом квадрате
- Нижняя часть: название, описание, ссылка "Подробнее →"
- Hover-эффект: поднятие тени (hover:shadow-xl)
---
### 4. SOLUTIONS (Solutions.tsx)
**6 отраслевых решений (grid md:grid-cols-2 lg:grid-cols-3):**
1. Корпоративный сектор (Building2) — 1200+ проектов
2. Ритейл и e-commerce (ShoppingCart) — 850+ проектов
3. Производство (Factory) — 650+ проектов
4. Здравоохранение (Heart) — 420+ проектов
5. Образование (GraduationCap) — 380+ проектов
6. Государственный сектор (Landmark) — 500+ проектов
**Структура карточки:**
- Иконка в синем градиентном квадрате (bg-gradient-to-br from-blue-500 to-blue-600)
- Название и описание
- Количество проектов (в нижней части, выделено синим цветом)
---
### 5. CASES (Cases.tsx)
**3 кейса в сетке (grid lg:grid-cols-3):**
1. **Миграция в Яндекс.Облако** (Cloud icon, голубой градиент)
- Клиент: Торговая сеть
- Описание: Перенос 500+ серверов с нулевым простоем
- Результат: −40% TCO
2. **Внедрение 1С:ERP** (Cpu icon, зеленый градиент)
- Клиент: Производственный холдинг
- Описание: Автоматизация для 15 предприятий
- Результат: +25% эффективность
3. **Защита периметра Kaspersky** (Shield icon, фиолетовый градиент)
- Клиент: Финансовая группа
- Описание: Многоуровневая система защиты
- Результат: 99.9% uptime
**Структура карточки:**
- Верх: градиентный фон с крупной иконкой и бейджем категории
- Низ: компания, заголовок, описание, результат
- Кнопка "Все кейсы" под карточками
---
### 6. ABOUT (About.tsx)
**Заголовок с анимацией:**
"Сотрудничаем с [название вендора]" — где название вендора циклически меняется каждые 4.5 секунды с fade-эффектом.
**56 российских вендоров для ротации:**
Яндекс.Облако, VK Cloud, MTC Web Services, Сбер Облако, Kaspersky, Positive Technologies, UserGate, Astra Linux, РедСофт, Альт Линукс, Postgres Pro, Tarantool, Мой Офис, Р7-Офис, 1С, Галактика, Kraftway, Aquarius, Эльбрус, Битрикс24, Код Безопасности, Zabbix, Nginx, Selectel, Лаборатория Эльбрус, Basealt, МойОфис Почта, Видеомост, Jazz Telecom, Р-Видео, СКБ Контур, ЕТИС, Кодикс, Тензор, Infowatch, SearchInform, Гарда, Аладдин Р.Д., С-Терра, DeviceLock, КриптоПро, Базальт СПО, Аскон, НаноСофт, Directum, Open Yard, ТруКонф, Yadro, Depo, Eltex, Qtech, D-Link
**6 ключевых преимуществ (grid md:grid-cols-2 lg:grid-cols-3):**
1. Поставка оборудования (Package icon, синий)
2. Официальное партнерство (Award icon, зеленый)
3. Сертифицированные специалисты (CheckCircle2 icon, фиолетовый)
4. Оперативный отклик (Zap icon, оранжевый)
5. Выделенная команда (Users icon, голубой)
6. Быстрый старт (Rocket icon, розовый)
---
### 7. CONTACT (Contact.tsx)
**Форма обратной связи в белой карточке:**
Поля:
- Имя (обязательное)
- Компания
- Email (обязательное)
- Телефон (обязательное)
- Сообщение (textarea, 4 строки)
Кнопка: "Отправить заявку" (синяя)
**Контактная информация снизу (в строку):**
- Телефон: +7 (495) 123-45-67
- Email: info@softclick.ru
- Адрес: Москва, Пресненская наб., 12
- Часы: Пн-Пт 9:00–18:00
---
### 8. FOOTER (Footer.tsx)
**4 колонки:**
1. **Логотип и соцсети:**
- Логотип SOFTCLICK
- Описание компании
- Иконки соцсетей: Telegram, VK, Habr, VK Video
2. **Решения:**
- Облачные решения
- Кибербезопасность
- Цифровая трансформация
- Управление данными
3. **Компания:**
- О нас
- Карьера
- Новости
- Партнерам
4. **Контакты:**
- Телефон с часами работы
- Email
- Адрес
**Подвал:**
- © 2025 SOFTCLICK. Все права защищены.
- Ссылки: Политика конфиденциальности, Условия использования
**Стилизация:**
- Темный фон (bg-gray-900), светлый текст (text-gray-400)
- Ссылки hover: text-blue-400
---
### 9. CONTACT MODAL (ContactModal.tsx)
**Модальное окно с расширенной формой:**
Открывается по кнопкам "Связаться" и "Обсудить проект"
**Поля формы:**
1. Имя (обязательное)
2. Компания
3. Email (обязательное)
4. Телефон (обязательное)
5. **Какую задачу решаете?** (select с опциями):
- 🔥 Всё горит, нужна помощь! (selected по умолчанию)
- Модернизация инфраструктуры
- Замена импортного ПО на российское
- Расширение мощностей
- Создание новой инфраструктуры
- Повышение безопасности
- Автоматизация процессов
- Миграция в облако
- Резервирование и восстановление
- Соответствие требованиям регуляторов
- Другое
6. **Сроки реализации** (select):
- Еще вчера (selected по умолчанию)
- Срочно (до 1 месяца)
- 1-3 месяца
- 3-6 месяцев
- 6-12 месяцев
- Более 12 месяцев
- На стадии планирования
7. Комментарий к задаче (textarea)
8. **Предпочитаемое время для встречи в ВКС:**
- Вариант 1 (datetime-local)
- Вариант 2 (datetime-local)
- Подсказка: "Мы отправим вам приглашение в ВКС на один из выбранных слотов"
**Технические детали:**
- Рендерится через createPortal(modalContent, document.body)
- При открытии блокирует скролл страницы
- Закрытие по клику на оверлей или крестик
- z-index: 9999
- Backdrop: bg-black/60 backdrop-blur-sm
---
### 10. CATEGORY PAGE (CategoryPage.tsx)
**Универсальный шаблон для всех страниц категорий**
**Hero-секция:**
- Кнопка "Назад к главной" (ArrowLeft icon)
- Бейдж "Каталог решений"
- Заголовок категории (text-5xl lg:text-6xl)
- Описание категории
- Декоративный элемент справа (на xl-экранах)
**Сетка вендоров (grid md:grid-cols-2 lg:grid-cols-3):**
Каждая карточка вендора:
- Название вендора (text-2xl, hover:text-blue-600)
- Описание
- Список продуктов/решений (если есть) в синих бейджах (bg-blue-50)
- Кнопка "Узнать больше" внизу
---
## СТРАНИЦЫ КАТЕГОРИЙ
### РЕШЕНИЯ (10 страниц):
**1. CloudPage — Облачные решения**
Вендоры: Яндекс.Облако, VK Cloud, Cloud.ru, SberCloud, МТС Cloud, Selectel
Продукты: Compute Cloud, Object Storage, Kubernetes, DataLens, ML Platform, CDN и т.д.
**2. RussianSoftwarePage — Российское ПО**
Вендоры: МойОфис, 1С, Галактика, Р7-Офис, Postgres Pro, Astra Linux, РедСофт, BaseALT, ELMA
Продукты: Офисные пакеты, ERP, СУБД, ОС
**3. CybersecurityPage — Кибербезопасность**
Вендоры: Касперский, Positive Technologies, InfoWatch, SearchInform, Гарда, DeviceLock, КриптоПро, Код Безопасности, Аладдин Р.Д.
Продукты: Endpoint Security, DLP, WAF, VPN, Firewall, ЭЦП
**4. AISolutionsPage — Решения на базе ИИ**
Вендоры: Яндекс.Облако ML, SberCloud AI, VK Cloud ML, GigaChat, Kandinsky, YandexGPT
Продукты: ML Platform, NLP, Computer Vision, Speech Recognition
**5. RussianHardwarePage — Российские ноутбуки, ПК и серверы**
Вендоры: Kraftway, Aquarius, Эльбрус, Байкал Электроникс, ИВК, DEPO, Yadro
Продукты: Рабочие станции, серверы, ноутбуки
**6. HardwareSolutionsPage — Аппаратные решения**
Вендоры: Huawei, Cisco (для России), Eltex, Qtech, D-Link, МикроТик, SNR
Продукты: Коммутаторы, маршрутизаторы, СХД, серверное оборудование
**7. CADandGISPage — САПР и ГИС**
Вендоры: Аскон (КОМПАС-3D), НаноСофт (nanoCAD), ГеоС, Panorama, ИндорСофт, Топоматик
Продукты: KOMPAS-3D, nanoCAD, ГИС Панорама
**8. IndustrialSolutionsPage — Индустриальные решения**
Вендоры: SCADA системы (Trace Mode, MasterSCADA), АСУ ТП, IIoT платформы
Продукты: Системы мониторинга, управления производством
**9. GovernmentPage — Для госсектора**
Вендоры: Astra Linux SE, Мой Офис Госы, КриптоПро, Postgres Pro Certified, Континент
Продукты: Защищенные ОС, офисное ПО для госорганов
**10. BusinessPage — Решения для бизнеса**
Вендоры: Битрикс24, ELMA365, Comindware, Directum, СКБ Контур
Продукты: CRM, BPM, ECM, электронная отчетность
---
### УСЛУГИ (7 страниц):
**1. ConsultingPage — Консалтинг и проектирование**
Услуги: IT-стратегия, Миграция на российское ПО, Оптимизация инфраструктуры, Проектирование ЦОД, Импортозамещение, Бизнес-анализ
**2. ImplementationPage — Поставка и внедрение решений**
Услуги: Поставка оборудования, Внедрение ПО, Настройка и интеграция, Пусконаладка, Обучение пользователей, Приемка систем
**3. ManagedServicesPage — Управляемые сервисы**
Услуги: Мониторинг 24/7, Управление инфраструктурой, Backup as a Service, DRaaS, Patch Management, SOC
**4. IntegrationPage — Интеграция и разработка**
Услуги: Системная интеграция, Кастомная разработка, API-интеграция, Миграция данных, DevOps, CI/CD
**5. SpecializedServicesPage — Специализированные услуги по отраслям**
Услуги по отраслям: Производство, Ритейл, Финансы, Госсектор, Медицина, Телеком
**6. SupportPage — Обслуживание и сопровождение**
Услуги: Техподдержка L1-L3, SLA гарантии, Плановое обслуживание, Инцидент-менеджмент, Change Management
**7. SubscriptionPage — Услуги по подписке**
Услуги: Cloud Subscriptions, Software Subscriptions, Hardware as a Service, Managed Services по подписке
---
## СТИЛИ И АНИМАЦИИ
### globals.css
**CSS-переменные:**
```css
:root {
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--primary: #030213;
--border: rgba(0, 0, 0, 0.1);
--radius: 0.625rem;
}
```
**Базовая типографика:**
- h1: text-2xl, font-weight: 500
- h2: text-xl, font-weight: 500
- p, input, button: text-base, font-weight: 400
### animations.css
**Float-анимация для иконок:**
```css
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-float { animation: float 3s ease-in-out infinite; }
.animate-float-delay-1 { animation-delay: 0.5s; }
.animate-float-delay-2 { animation-delay: 1s; }
.animate-float-delay-3 { animation-delay: 1.5s; }
.animate-float-delay-4 { animation-delay: 2s; }
.animate-float-delay-5 { animation-delay: 2.5s; }
```
---
## НАВИГАЦИЯ И РОУТИНГ
### App.tsx — State-based роутинг
**Логика навигации:**
- Используется useState для хранения текущей страницы (currentPage)
- При клике на пункт меню вызывается handleNavigate(page, section?)
- Если передана section — скроллит к якорю на главной странице
- Если не на главной — сначала переходит на главную, затем скроллит (через pendingScroll)
- При переходе между страницами — скролл наверх
**Страницы:**
- home (HomePage) — главная
- cloud, russian-software, cybersecurity, ai-solutions, russian-hardware, hardware-solutions, cad-gis, industrial-solutions, government, business — 10 страниц решений
- consulting, implementation, managed-services, integration, specialized-services, support, subscription — 7 страниц услуг
---
## КОНФИГУРАЦИЯ СБОРКИ
### package.json
```json
{
"name": "softclick-corporate-website",
"engines": {
"node": ">=18.0.0 <22.0.0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^0.344.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"tailwindcss": "^4.0.0",
"vite": "^5.1.4"
}
}
```
### vite.config.ts
```ts
export default defineConfig({
build: {
outDir: 'build', // ВАЖНО: папка build для Netlify
}
});
```
### netlify.toml
```toml
[build]
command = "npm run build"
publish = "build"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[build.environment]
NODE_VERSION = "18"
```
---
## ВАЖНЫЕ ДЕТАЛИ
1. **Дата основания:** Везде указывать "с 2021 года"
2. **Метрики компании:**
- 300+ клиентов
- 50+ партнерств
- 95% повторных обращений
3. **Цвета:**
- Основной акцент: #2563eb (blue-600)
- Фон: белый (#ffffff)
- Текст: серый (gray-600, gray-700, gray-900)
- Градиенты: использовать Tailwind-классы (from-blue-500 to-blue-600)
4. **Скругления:**
- Карточки: rounded-2xl, rounded-3xl
- Кнопки: rounded-lg, rounded-xl
- Инпуты: rounded-xl
5. **Тени:**
- Карточки: border border-gray-100, hover:shadow-xl
- Модальное окно: shadow-2xl
6. **Иконки:**
- Размер: w-5 h-5 для мелких, w-12 h-12 для крупных
- Цвет: соответствует тематике секции
7. **Отступы:**
- Максимальная ширина контейнера: max-w-[1600px]
- Горизонтальные отступы: px-6 xl:px-12 2xl:px-16
- Вертикальные отступы секций: py-20, py-24
8. **Responsive:**
- Мобильная версия: стековые блоки
- Десктоп: сетки grid md:grid-cols-2 lg:grid-cols-3
9. **Hover-эффекты:**
- Кнопки: hover:bg-blue-700, hover:shadow-lg
- Карточки: hover:border-blue-200, hover:shadow-xl
- Ссылки: hover:text-blue-600
- Иконки: group-hover:scale-110, group-hover:translate-x-1
10. **Контактная информация:**
- Телефон: +7 (495) 123-45-67
- Email: info@softclick.ru
- Адрес: Москва, Пресненская наб., 12
- Время работы: Пн-Пт 9:00–18:00
---
## ФАЙЛОВАЯ СТРУКТУРА (полный список)
```
/
├── App.tsx (главный компонент с роутингом)
├── main.tsx (точка входа)
├── index.html
├── package.json
├── vite.config.ts
├── netlify.toml
├── tsconfig.json
├── postcss.config.js
├── components/
│ ├── Header.tsx
│ ├── Hero.tsx
│ ├── Services.tsx
│ ├── Solutions.tsx
│ ├── Cases.tsx
│ ├── About.tsx
│ ├── Contact.tsx
│ ├── Footer.tsx
│ ├── ContactModal.tsx
│ └── CategoryPage.tsx
├── pages/
│ ├── HomePage.tsx
│ ├── CloudPage.tsx
│ ├── RussianSoftwarePage.tsx
│ ├── CybersecurityPage.tsx
│ ├── AISolutionsPage.tsx
│ ├── RussianHardwarePage.tsx
│ ├── HardwareSolutionsPage.tsx
│ ├── CADandGISPage.tsx
│ ├── IndustrialSolutionsPage.tsx
│ ├── GovernmentPage.tsx
│ ├── BusinessPage.tsx
│ ├── ConsultingPage.tsx
│ ├── ImplementationPage.tsx
│ ├── ManagedServicesPage.tsx
│ ├── IntegrationPage.tsx
│ ├── SpecializedServicesPage.tsx
│ ├── SupportPage.tsx
│ └── SubscriptionPage.tsx
└── styles/
├── globals.css
└── animations.css
```
---
## ИТОГОВЫЕ ТРЕБОВАНИЯ К РЕЗУЛЬТАТУ
1. Полностью рабочий SPA на React + TypeScript
2. Адаптивная верстка под все экраны
3. Плавные анимации и transitions
4. Минималистичный дизайн БЕЗ фотографий
5. Все компоненты должны быть функциональными (useState, useEffect)
6. Корректная навигация между всеми страницами
7. Работающие выпадающие меню
8. Функциональное модальное окно
9. Якорная навигация к секциям на главной странице
10. Готовность к деплою на Netlify (build в папку build)
---
## ПРИМЕР КОДА КАРТОЧКИ ВЕНДОРА
```tsx
<div className="bg-white rounded-2xl p-8 hover:shadow-lg transition-all border border-gray-100 group">
<h3 className="text-2xl text-gray-900 group-hover:text-blue-600 transition-colors">
Яндекс.Облако
</h3>
<p className="text-gray-600 mt-4">
Полный спектр облачных сервисов: вычисления, хранение данных, машинное обучение
</p>
<div className="mt-4 flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm">
Compute Cloud
</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm">
Object Storage
</span>
</div>
<button className="mt-6 w-full py-3 bg-blue-50 text-blue-600 rounded-xl hover:bg-blue-600 hover:text-white transition-all">
Узнать больше
</button>
</div>
```
---
Этот промт содержит все необходимые данные для точного воссоздания сайта SOFTCLICK. Следуй ему последовательно, создавая каждый компонент с указанными стилями и структурой.

116
src/README.md Normal file
View File

@@ -0,0 +1,116 @@
# SOFTCLICK - Корпоративный сайт
Современный корпоративный сайт российской IT-компании SOFTCLICK с фокусом на импортозамещение и российские решения.
## ⚠️ Требования
- **Node.js 18 LTS** (рекомендуется)
- npm 9 или выше
Проект настроен на Node.js 18 для максимальной совместимости. Node.js 22 может вызвать проблемы.
**Установка Node.js 18:**
```bash
# Если используете nvm
nvm install 18
nvm use 18
# Проверка версии
node -v # должно показать v18.x.x
```
## ⚠️ Важно: Проверка сборки
Перед деплоем проверьте, в какую папку собирается проект:
**Linux/Mac:**
```bash
chmod +x check-build.sh
./check-build.sh
```
**Windows:**
```cmd
check-build.bat
```
Скрипт покажет, создаётся папка `dist` или `build`. Используйте эту информацию для настройки деплоя.
## Технологии
- React 18
- TypeScript
- Vite
- Tailwind CSS v4
- Lucide Icons
## Установка и запуск
```bash
# Установка зависимостей
npm install
# Запуск dev-сервера
npm run dev
# Сборка для продакшена
npm run build
# Предпросмотр продакшен-сборки
npm run preview
```
## 🚀 Деплой
### Быстрый деплой (один клик)
**Linux/Mac:**
```bash
chmod +x deploy.sh
./deploy.sh
```
**Windows:**
```cmd
deploy.bat
```
### Рекомендуемые платформы
#### ⭐ Netlify (самый простой - 2 минуты)
1. Зарегистрируйтесь на https://netlify.com
2. Подключите GitHub репозиторий
3. Готово! Автоматический деплой настроен
#### ⭐ Cloudflare Pages (самый быстрый)
1. Зарегистрируйтесь на https://pages.cloudflare.com
2. Build command: `npm run build`
3. Output: `dist`
#### ⭐ GitHub Pages (100% бесплатно)
1. Settings → Pages → Source: "GitHub Actions"
2. Автоматический деплой уже настроен!
#### 🇷🇺 Яндекс.Облако (российская платформа)
См. подробную инструкцию в `deploy-yandex-cloud.md`
#### 🇷🇺 Традиционный хостинг (Timeweb, Beget, Reg.ru)
См. инструкцию в `deploy-traditional-hosting.md`
### 📖 Полная документация по деплою
**Смотрите файл `DEPLOYMENT.md`** - там все варианты с пошаговыми инструкциями!
## Структура проекта
- `/components` - React компоненты
- `/pages` - Страницы приложения
- `/styles` - Глобальные стили и анимации
- `/App.tsx` - Главный компонент с роутингом
## Файлы конфигурации
- `netlify.toml` - конфигурация для Netlify
- `.github/workflows/deploy.yml` - GitHub Pages автодеплой
- `.github/workflows/deploy-yandex.yml` - Яндекс.Облако автодеплой
- `.htaccess` - для Apache хостинга
- `vercel.json` - для Vercel (опционально)

118
src/START-HERE.md Normal file
View File

@@ -0,0 +1,118 @@
# 🚀 НАЧНИТЕ ОТСЮДА
## Проект готов к деплою на Netlify!
Все проблемы исправлены:
- ✅ Node.js зафиксирован на версии 18 LTS
- ✅ Vite настроен на сборку в папку `build`
- ✅ Netlify настроен на публикацию из `build`
- ✅ Все конфигурации обновлены
---
## 🎯 Быстрый старт
### 1. Закоммитьте изменения
```bash
git add .
git commit -m "Fix: Pin Node.js to v18 and configure build output"
git push origin main
```
### 2. Деплой на Netlify
После пуша:
1. Зайдите на https://app.netlify.com
2. Откройте ваш сайт
3. Дождитесь автоматического деплоя (~2-3 минуты)
4. Готово! ✅
---
## 📖 Документация
### Для быстрого решения проблем:
- **COMMIT-AND-DEPLOY.md** - пошаговая инструкция по деплою
- **NETLIFY-FIX.md** - решение проблем с Netlify
- **NODE-VERSION-FIX.md** - информация о Node.js версии
### Полная информация:
- **DEPLOYMENT.md** - все способы деплоя
- **PLATFORMS.md** - сравнение платформ
- **DEPLOY-QUICK.md** - быстрая шпаргалка
### История изменений:
- **LATEST-FIXES.md** - последние исправления
- **FIXES-SUMMARY.md** - полная сводка всех исправлений
---
## 🔍 Проверка перед коммитом (опционально)
```bash
# Проверьте версию Node.js
node -v # Должно быть v18.x.x
# Если нет, установите:
nvm install 18
nvm use 18
# Запустите скрипт проверки сборки
chmod +x check-build.sh
./check-build.sh
# Должно показать:
# ✅ Node.js версия совместима
# ✅ BUILD OUTPUT: build
```
---
## ❓ Что дальше?
После успешного деплоя на Netlify:
### Базовые настройки:
- [ ] Проверьте что сайт работает
- [ ] Настройте кастомный домен (если нужно)
- [ ] SSL сертификат (Netlify настроит автоматически)
### Дополнительные возможности:
- [ ] Подключите Yandex.Metrika или Google Analytics
- [ ] Настройте форму обратной связи
- [ ] Добавьте favicon (уже есть в `/public/favicon.svg`)
- [ ] Настройте redirects для старых URL (если нужно)
---
## 🆘 Проблемы с Netlify?
### Вариант 1: Используйте Cloudflare Pages
```
1. https://pages.cloudflare.com
2. Build: npm run build
3. Output: build
4. Environment: NODE_VERSION = 18
```
### Вариант 2: GitHub Pages
Создайте файл `.github/workflows/deploy.yml` (см. документацию)
---
## ✅ Быстрая команда для деплоя
```bash
# Всё в одной команде
git add . && \
git commit -m "Fix deployment config" && \
git push && \
echo "✅ Pushed! Check Netlify for deploy status."
```
---
**Удачи с деплоем!** 🎉
Если возникнут вопросы - см. файл **COMMIT-AND-DEPLOY.md** для детальных инструкций.

8
src/_headers/main.tsx Normal file
View File

@@ -0,0 +1,8 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Cache-Control: public, max-age=31536000, immutable
/*.html
Cache-Control: public, max-age=0, must-revalidate

1
src/_redirects/main.tsx Normal file
View File

@@ -0,0 +1 @@
/* /index.html 200

54
src/check-build.bat Normal file
View File

@@ -0,0 +1,54 @@
@echo off
chcp 65001 >nul
echo 🔍 Проверка сборки SOFTCLICK...
echo.
REM Проверка версии Node.js
echo 📌 Проверка версии Node.js...
node -v
echo.
REM Очистка старых артефактов
echo 🧹 Очистка старых сборок...
if exist dist rmdir /s /q dist
if exist build rmdir /s /q build
REM Установка зависимостей
echo 📦 Установка зависимостей...
call npm install
REM Сборка
echo 🔨 Сборка проекта...
call npm run build
REM Проверка результата
echo.
echo ✅ Проверка результатов сборки:
echo.
if exist dist\ (
echo 📁 Найдена папка: dist\
dir dist /s
echo.
echo ✅ BUILD OUTPUT: dist
echo.
echo 📋 Для Netlify используйте:
echo publish = "dist"
) else if exist build\ (
echo 📁 Найдена папка: build\
dir build /s
echo.
echo ✅ BUILD OUTPUT: build
echo.
echo 📋 Для Netlify используйте:
echo publish = "build"
) else (
echo ❌ Папка сборки не найдена!
echo Проверьте логи выше на наличие ошибок
pause
exit /b 1
)
echo.
pause

68
src/check-build.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
echo "🔍 Проверка сборки SOFTCLICK..."
echo ""
# Очистка старых артефактов
echo "🧹 Очистка старых сборок..."
rm -rf dist build
# Проверка Node.js версии
echo "📌 Проверка версии Node.js..."
node_version=$(node -v)
echo " Текущая версия: $node_version"
if [[ "$node_version" == v18* ]] || [[ "$node_version" == v20* ]]; then
echo " ✅ Node.js версия совместима"
elif [[ "$node_version" == v22* ]]; then
echo " ⚠️ Предупреждение: Node.js v22 может вызвать проблемы"
echo " 💡 Рекомендуется использовать Node.js v18 LTS"
echo " Установите через: nvm install 18 && nvm use 18"
else
echo " ℹ️ Версия: $node_version"
fi
echo ""
# Установка зависимостей
echo "📦 Установка зависимостей..."
npm install
# Сборка
echo "🔨 Сборка проекта..."
npm run build
# Проверка результата
echo ""
echo "✅ Проверка результатов сборки:"
echo ""
if [ -d "dist" ]; then
echo "📁 Найдена папка: dist/"
echo " Размер: $(du -sh dist | cut -f1)"
echo " Файлов: $(find dist -type f | wc -l)"
echo ""
echo "Содержимое dist/:"
ls -lh dist/
echo ""
echo "✅ BUILD OUTPUT: dist"
elif [ -d "build" ]; then
echo "📁 Найдена папка: build/"
echo " Размер: $(du -sh build | cut -f1)"
echo " Файлов: $(find build -type f | wc -l)"
echo ""
echo "Содержимое build/:"
ls -lh build/
echo ""
echo "✅ BUILD OUTPUT: build"
else
echo "❌ Папка сборки не найдена!"
echo " Проверьте логи выше на наличие ошибок"
exit 1
fi
echo ""
echo "📋 Для Netlify используйте в netlify.toml:"
if [ -d "dist" ]; then
echo ' publish = "dist"'
elif [ -d "build" ]; then
echo ' publish = "build"'
fi

162
src/components/About.tsx Normal file
View File

@@ -0,0 +1,162 @@
import { CheckCircle, Rocket, Zap, Target, Award, Shield, Clock, HeadphonesIcon, TrendingUp, Users, CheckCircle2, Cloud, Lock, Database, Briefcase, Server, Cpu, Package } from 'lucide-react';
import { useState, useEffect } from 'react';
const allVendors = [
'Яндекс.Облако',
'VK Cloud',
'MTC Web Services',
'Сбер Облако',
'Kaspersky',
'Positive Technologies',
'UserGate',
'Astra Linux',
'РедСофт',
'Альт Линукс',
'Postgres Pro',
'Tarantool',
'Мой Офис',
'Р7-Офис',
'1С',
'Галактика',
'Kraftway',
'Aquarius',
'Эльбрус',
'Битрикс24',
'Код Безопасности',
'Zabbix',
'Nginx',
'Selectel',
'Лаборатория Эльбрус',
'Basealt',
'МойОфис Почта',
'Видеомост',
'Jazz Telecom',
'Р-Видео',
'СКБ Контур',
'ЕТИС',
'Кодикс',
'Тензор',
'Infowatch',
'SearchInform',
'Гарда',
'Аладдин Р.Д.',
'С-Терра',
'DeviceLock',
'КриптоПро',
'Базальт СПО',
'Аскон',
'НаноСофт',
'Directum',
'Open Yard',
'ТруКонф',
'Yadro',
'Depo',
'Eltex',
'Qtech',
'D-Link'
];
export function About() {
const [currentVendorIndex, setCurrentVendorIndex] = useState(0);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const interval = setInterval(() => {
setIsVisible(false);
setTimeout(() => {
setCurrentVendorIndex((prev) => (prev + 1) % allVendors.length);
setIsVisible(true);
}, 800);
}, 4500);
return () => clearInterval(interval);
}, []);
return (
<section className="py-4 bg-gray-50">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<div className="max-w-3xl mb-16">
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">
О компании
</p>
<h2 className="text-4xl lg:text-5xl text-gray-900 mb-6 flex flex-wrap items-baseline gap-2">
<span>Сотрудничаем с</span>
<span
className="text-blue-600 transition-opacity duration-700 ease-in-out whitespace-nowrap"
style={{ opacity: isVisible ? 1 : 0 }}
>
{allVendors[currentVendorIndex]}
</span>
</h2>
<p className="text-xl text-gray-600">
Комплексные решения для цифровой трансформации бизнеса. Работаем с 2021 года.
</p>
</div>
{/* Ключевые преимущества для клиента */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white p-8 rounded-3xl border border-gray-100 hover:border-blue-200 hover:shadow-lg transition-all">
<div className="w-14 h-14 bg-blue-50 rounded-2xl flex items-center justify-center mb-6">
<Package className="w-7 h-7 text-blue-600" />
</div>
<h3 className="text-xl text-gray-900 mb-3">Поставка оборудования</h3>
<p className="text-gray-600 leading-relaxed">
Комплексные поставки серверов, СХД и сетевого оборудования от российских производителей.
</p>
</div>
<div className="bg-white p-8 rounded-3xl border border-gray-100 hover:border-blue-200 hover:shadow-lg transition-all">
<div className="w-14 h-14 bg-emerald-50 rounded-2xl flex items-center justify-center mb-6">
<Award className="w-7 h-7 text-emerald-600" />
</div>
<h3 className="text-xl text-gray-900 mb-3">Официальное партнерство</h3>
<p className="text-gray-600 leading-relaxed">
Статус золотого партнера ведущих российских вендоров. Прямые контракты и поддержка.
</p>
</div>
<div className="bg-white p-8 rounded-3xl border border-gray-100 hover:border-blue-200 hover:shadow-lg transition-all">
<div className="w-14 h-14 bg-violet-50 rounded-2xl flex items-center justify-center mb-6">
<CheckCircle2 className="w-7 h-7 text-violet-600" />
</div>
<h3 className="text-xl text-gray-900 mb-3">Сертифицированные специалисты</h3>
<p className="text-gray-600 leading-relaxed">
Инженеры с подтвержденной квалификацией по всем внедряемым решениям и платформам.
</p>
</div>
<div className="bg-white p-8 rounded-3xl border border-gray-100 hover:border-blue-200 hover:shadow-lg transition-all">
<div className="w-14 h-14 bg-orange-50 rounded-2xl flex items-center justify-center mb-6">
<Zap className="w-7 h-7 text-orange-600" />
</div>
<h3 className="text-xl text-gray-900 mb-3">Оперативный отклик</h3>
<p className="text-gray-600 leading-relaxed">
Быстрое реагирование на запросы клиента. Четкие сроки решения задач и прозрачная коммуникация.
</p>
</div>
<div className="bg-white p-8 rounded-3xl border border-gray-100 hover:border-blue-200 hover:shadow-lg transition-all">
<div className="w-14 h-14 bg-cyan-50 rounded-2xl flex items-center justify-center mb-6">
<Users className="w-7 h-7 text-cyan-600" />
</div>
<h3 className="text-xl text-gray-900 mb-3">Выделенная команда</h3>
<p className="text-gray-600 leading-relaxed">
Персональный менеджер проекта и команда сертифицированных специалистов.
</p>
</div>
<div className="bg-white p-8 rounded-3xl border border-gray-100 hover:border-blue-200 hover:shadow-lg transition-all">
<div className="w-14 h-14 bg-pink-50 rounded-2xl flex items-center justify-center mb-6">
<Rocket className="w-7 h-7 text-pink-600" />
</div>
<h3 className="text-xl text-gray-900 mb-3">Быстрый старт</h3>
<p className="text-gray-600 leading-relaxed">
Запуск типовых проектов за 2 недели. Пилотное внедрение перед полным развертываием.
</p>
</div>
</div>
</div>
</section>
);
}

109
src/components/Cases.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { ArrowRight, Cloud, Cpu, Shield } from 'lucide-react';
const cases = [
{
icon: Cloud,
category: 'Облако',
company: 'Торговая сеть',
title: 'Миграция в Яндекс.Облако',
description: 'Перенос корпоративной инфраструктуры 500+ серверов с нулевым простоем',
results: '−40% TCO',
gradient: 'from-blue-100 to-cyan-100',
iconBg: 'bg-white',
iconColor: 'text-blue-600'
},
{
icon: Cpu,
category: 'Автоматизация',
company: 'Производственный холдинг',
title: 'Внедрение 1С:ERP',
description: 'Автоматизация производственного и финансового учета для 15 предприятий',
results: '+25% эффективность',
gradient: 'from-emerald-100 to-teal-100',
iconBg: 'bg-white',
iconColor: 'text-emerald-600'
},
{
icon: Shield,
category: 'Безопасность',
company: 'Финансовая группа',
title: 'Защита периметра Kaspersky',
description: 'Построение многоуровневой системы защиты критичной инфраструктуры',
results: '99.9% uptime',
gradient: 'from-violet-100 to-purple-100',
iconBg: 'bg-white',
iconColor: 'text-violet-600'
}
];
export function Cases() {
return (
<section className="py-4 bg-white">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<div className="max-w-3xl mb-16">
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">
Кейсы
</p>
<h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">
Реализованные проекты
</h2>
<p className="text-xl text-gray-600">
Примеры успешного внедрения с конкретными результатами
</p>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{cases.map((caseItem, index) => {
const Icon = caseItem.icon;
const floatClass = index === 0 ? 'animate-float' : index === 1 ? 'animate-float-delay-1' : 'animate-float-delay-2';
return (
<div
key={index}
className="group bg-white rounded-2xl overflow-hidden border border-gray-100 hover:border-blue-200 hover:shadow-xl transition-all duration-300 cursor-pointer flex flex-col"
>
<div className={`relative h-64 overflow-hidden bg-gradient-to-br ${caseItem.gradient} flex items-center justify-center`}>
<div className={`w-24 h-24 ${caseItem.iconBg} rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300 shadow-md ${floatClass}`}>
<Icon className={`w-12 h-12 ${caseItem.iconColor}`} />
</div>
<div className="absolute top-4 left-4">
<span className="px-3 py-1 bg-white/90 backdrop-blur-sm text-gray-900 rounded-full border border-gray-200 text-sm">
{caseItem.category}
</span>
</div>
</div>
<div className="p-6 flex flex-col flex-1">
<div className="text-sm text-blue-600 mb-2">
{caseItem.company}
</div>
<h3 className="text-xl text-gray-900 mb-3">
{caseItem.title}
</h3>
<p className="text-gray-600 mb-4 leading-relaxed flex-1">
{caseItem.description}
</p>
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
<span className="text-gray-900">
{caseItem.results}
</span>
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div>
</div>
</div>
);
})}
</div>
<div className="text-center mt-12">
<button className="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all shadow-md hover:shadow-lg inline-flex items-center gap-2">
Все кейсы
<ArrowRight className="w-5 h-5" />
</button>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,96 @@
import { ArrowLeft } from 'lucide-react';
interface Vendor {
name: string;
description: string;
logo?: string;
products?: string[];
}
interface CategoryPageProps {
title: string;
description: string;
vendors: Vendor[];
onBack: () => void;
}
export function CategoryPage({ title, description, vendors, onBack }: CategoryPageProps) {
return (
<div className="min-h-screen bg-gray-50">
{/* Hero секция */}
<div className="bg-white border-b border-gray-100 pt-32 pb-16">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<button
onClick={onBack}
className="flex items-center gap-2 text-gray-600 hover:text-blue-600 mb-8 transition-colors group"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
Назад к главной
</button>
<div className="flex items-start gap-8">
<div className="flex-1">
<div className="inline-block px-4 py-1.5 bg-blue-50 text-blue-600 rounded-full text-sm mb-6">
Каталог решений
</div>
<h1 className="text-5xl lg:text-6xl mb-6 leading-tight text-gray-900">
{title}
</h1>
<p className="text-xl text-gray-600 max-w-3xl leading-relaxed">
{description}
</p>
</div>
<div className="hidden xl:block w-32 h-32 bg-gradient-to-br from-blue-500/10 to-blue-600/10 rounded-3xl flex-shrink-0"></div>
</div>
</div>
</div>
{/* Список вендоров */}
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16 py-20">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{vendors.map((vendor, index) => (
<div
key={index}
className="bg-white rounded-2xl p-8 hover:shadow-lg transition-all border border-gray-100 group flex flex-col"
>
<div className="space-y-4 flex-1 flex flex-col">
<div className="flex items-start justify-between">
<h3 className="text-2xl text-gray-900 group-hover:text-blue-600 transition-colors">
{vendor.name}
</h3>
</div>
<p className="text-gray-600 leading-relaxed">
{vendor.description}
</p>
{vendor.products && vendor.products.length > 0 && (
<div className="pt-4 border-t border-gray-100">
<div className="text-sm text-gray-500 mb-3 uppercase tracking-wider">
Решения
</div>
<div className="flex flex-wrap gap-2">
{vendor.products.map((product, idx) => (
<span
key={idx}
className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm"
>
{product}
</span>
))}
</div>
</div>
)}
<button className="mt-auto pt-6 w-full py-3 bg-blue-50 text-blue-600 rounded-xl hover:bg-blue-600 hover:text-white transition-all">
Узнать больше
</button>
</div>
</div>
))}
</div>
</div>
</div>
);
}

108
src/components/Contact.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { Phone, Mail, MapPin, Clock } from 'lucide-react';
export function Contact() {
return (
<section className="py-4 bg-gray-50">
<div className="max-w-[900px] mx-auto px-6">
{/* Заголовок */}
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl text-gray-900 mb-3">
Обсудим ваш проект
</h2>
<p className="text-gray-600">
Оставьте заявку — перезвоним в течение часа
</p>
</div>
{/* Форма */}
<div className="bg-white rounded-2xl p-8 shadow-sm border border-gray-100">
<form className="space-y-5">
<div className="grid md:grid-cols-2 gap-5">
<div>
<input
type="text"
id="name"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Ваше имя"
required
/>
</div>
<div>
<input
type="text"
id="company"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Компания"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-5">
<div>
<input
type="email"
id="email"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Email"
required
/>
</div>
<div>
<input
type="tel"
id="phone"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Телефон"
required
/>
</div>
</div>
<div>
<textarea
id="message"
rows={4}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all resize-none"
placeholder="Расскажите о вашем проекте"
></textarea>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
<button
type="submit"
className="w-full sm:w-auto px-8 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all"
>
Отправить заявку
</button>
<p className="text-sm text-gray-500">
Нажимая кнопку, вы соглашаетесь с политикой конфиденциальности
</p>
</div>
</form>
</div>
{/* Контакты компактно */}
<div className="mt-12 flex flex-wrap justify-center gap-8 text-sm text-gray-600">
<a href="tel:+74951234567" className="flex items-center gap-2 hover:text-blue-600 transition-colors">
<Phone className="w-4 h-4" />
<span>+7 (495) 123-45-67</span>
</a>
<a href="mailto:info@softclick.ru" className="flex items-center gap-2 hover:text-blue-600 transition-colors">
<Mail className="w-4 h-4" />
<span>info@softclick.ru</span>
</a>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>Москва, Пресненская наб., 12</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Пн-Пт 9:00–18:00</span>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,216 @@
import { X, Mail, Phone, MapPin, Clock, Calendar } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface ContactModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ContactModal({ isOpen, onClose }: ContactModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative bg-white rounded-2xl w-full max-w-[700px] max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Заголовок */}
<div className="sticky top-0 bg-white border-b border-gray-100 px-6 py-5 flex items-start justify-between z-10 rounded-t-2xl">
<div>
<h2 className="text-2xl text-gray-900">
Обсудим ваш проект
</h2>
<p className="text-sm text-gray-600 mt-1">
Оставьте заявку — перезвоним в течение часа
</p>
</div>
<button
onClick={onClose}
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors flex-shrink-0 ml-4"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Форма */}
<div className="p-8">
<form className="space-y-5">
<div className="grid md:grid-cols-2 gap-5">
<div>
<input
type="text"
id="modal-name"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Ваше имя"
required
/>
</div>
<div>
<input
type="text"
id="modal-company"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Компания"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-5">
<div>
<input
type="email"
id="modal-email"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Email"
required
/>
</div>
<div>
<input
type="tel"
id="modal-phone"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
placeholder="Телефон"
required
/>
</div>
</div>
{/* Для чего нужна покупка и Сроки */}
<div className="grid md:grid-cols-2 gap-5">
<div>
<label htmlFor="modal-purpose" className="block text-sm text-gray-700 mb-2">Какую задачу решаете?</label>
<select
id="modal-purpose"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
required
>
<option value="">Выберите цель покупки</option>
<option value="emergency" selected>🔥 Всё горит, нужна помощь!</option>
<option value="modernization">Модернизация инфраструктуры</option>
<option value="software-replacement">Замена импортного ПО на российское</option>
<option value="capacity-expansion">Расширение мощностей</option>
<option value="new-infrastructure">Создание новой инфраструктуры</option>
<option value="security-improvement">Повышение безопасности</option>
<option value="automation">Автоматизация процессов</option>
<option value="cloud-migration">Миграция в облако</option>
<option value="disaster-recovery">Резервирование и восстановление</option>
<option value="compliance">Соответствие требованиям регуляторов</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label htmlFor="modal-timeline" className="block text-sm text-gray-700 mb-2">Сроки реализации</label>
<select
id="modal-timeline"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
required
>
<option value="">Выберите срок</option>
<option value="yesterday" selected>Еще вчера</option>
<option value="urgent">Срочно (до 1 месяца)</option>
<option value="1-3-months">1-3 месяца</option>
<option value="3-6-months">3-6 месяцев</option>
<option value="6-12-months">6-12 месяцев</option>
<option value="12-months-plus">Более 12 месяцев</option>
<option value="planning">На стадии планирования</option>
</select>
</div>
</div>
{/* Комментарий к задаче */}
<div>
<label htmlFor="modal-task" className="block text-sm text-gray-700 mb-2">Комментарий к задаче</label>
<textarea
id="modal-task"
rows={4}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all resize-none"
placeholder="Дополнительная информация, требования, бюджет..."
></textarea>
</div>
{/* Слоты для встречи в ВКС */}
<div>
<label className="block text-sm text-gray-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
Предпочитаемое время для встречи в ВКС (выберите 2 варианта)
</label>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label htmlFor="slot1" className="block text-xs text-gray-600 mb-1">Вариант 1</label>
<input
type="datetime-local"
id="slot1"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
/>
</div>
<div>
<label htmlFor="slot2" className="block text-xs text-gray-600 mb-1">Вариант 2</label>
<input
type="datetime-local"
id="slot2"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
/>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">Мы отправим вам приглашение в ВКС на один из выбранных слотов</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4">
<button
type="submit"
className="w-full sm:w-auto px-8 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all"
>
Отправить заявку
</button>
<p className="text-sm text-gray-500">
Нажимая кнопку, вы соглашаетесь с политикой конфиденциальности
</p>
</div>
</form>
{/* Контакты компактно */}
<div className="mt-8 pt-8 border-t border-gray-100 flex flex-wrap justify-center gap-6 text-sm text-gray-600">
<a href="tel:+74951234567" className="flex items-center gap-2 hover:text-blue-600 transition-colors">
<Phone className="w-4 h-4" />
<span>+7 (495) 123-45-67</span>
</a>
<a href="mailto:info@softclick.ru" className="flex items-center gap-2 hover:text-blue-600 transition-colors">
<Mail className="w-4 h-4" />
<span>info@softclick.ru</span>
</a>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>Москва, Пресненская наб., 12</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Пн-Пт 9:00–18:00</span>
</div>
</div>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}

96
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { Mail, Phone, MapPin, Send, Hash, Code, Video } from 'lucide-react';
export function Footer() {
return (
<footer className="bg-gray-900 text-gray-400 pt-20 pb-10 border-t border-gray-800">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-12 mb-12">
<div>
<div className="flex items-center gap-3 mb-6">
<div className="w-9 h-9 bg-gradient-to-br from-blue-600 to-blue-700 rounded-lg flex items-center justify-center shadow-md">
<div className="w-5 h-5 border-2 border-white rounded"></div>
</div>
<span className="text-2xl text-white tracking-tight">SOFTCLICK</span>
</div>
<p className="mb-6 leading-relaxed">
Комплексные IT-решения для развития бизнеса
</p>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600" title="Telegram">
<Send className="w-5 h-5" />
</a>
<a href="#" className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600" title="VK">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.785 16.241s.288-.032.436-.19c.136-.145.131-.419.131-.419s-.019-1.281.576-1.47c.586-.185 1.341 1.238 2.138 1.788.604.417 1.064.325 1.064.325l2.137-.03s1.117-.069.587-.948c-.043-.072-.309-.651-1.589-1.84-1.339-1.246-1.16-1.044.453-3.197.983-1.312 1.376-2.113 1.253-2.456-.117-.328-.84-.241-.84-.241l-2.406.015s-.178-.024-.31.055c-.129.077-.212.258-.212.258s-.381.987-.887 1.827c-1.063 1.766-1.497 1.86-1.668 1.748-.395-.259-.296-1.04-.296-1.595 0-1.734.263-2.456-.512-2.642-.258-.062-.447-.102-1.106-.109-.845-.009-1.56.003-1.964.201-.269.132-.476.426-.35.443.156.021.509.095.696.349.241.328.233 1.064.233 1.064s.139 2.042-.324 2.296c-.318.174-.754-.182-1.69-1.806-.479-.821-.841-1.729-.841-1.729s-.07-.171-.194-.263c-.151-.111-.362-.146-.362-.146l-2.286.015s-.343.01-.469.159c-.112.132-.009.405-.009.405s1.789 4.182 3.814 6.291c1.857 1.935 3.965 1.808 3.965 1.808h.955z"/>
</svg>
</a>
<a href="#" className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600" title="Habr">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm4.326 14.467H7.674v-1.652h8.652v1.652zm0-3.239H7.674V9.576h8.652v1.652z"/>
</svg>
</a>
<a href="#" className="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center hover:bg-blue-600 transition-colors border border-gray-700 hover:border-blue-600" title="VK Video">
<Video className="w-5 h-5" />
</a>
</div>
</div>
<div>
<h4 className="text-white mb-6">Решения</h4>
<ul className="space-y-3">
<li><a href="#" className="hover:text-blue-400 transition-colors">Облачные решения</a></li>
<li><a href="#" className="hover:text-blue-400 transition-colors">Кибербезопасность</a></li>
<li><a href="#" className="hover:text-blue-400 transition-colors">Цифровая трансформация</a></li>
<li><a href="#" className="hover:text-blue-400 transition-colors">Управление данными</a></li>
</ul>
</div>
<div>
<h4 className="text-white mb-6">Компания</h4>
<ul className="space-y-3">
<li><a href="#" className="hover:text-blue-400 transition-colors">О нас</a></li>
<li><a href="#" className="hover:text-blue-400 transition-colors">Карьера</a></li>
<li><a href="#" className="hover:text-blue-400 transition-colors">Новости</a></li>
<li><a href="#" className="hover:text-blue-400 transition-colors">Партнерам</a></li>
</ul>
</div>
<div>
<h4 className="text-white mb-6">Контакты</h4>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<Phone className="w-5 h-5 mt-1 flex-shrink-0 text-blue-400" />
<div>
<div className="text-white">+7 (495) 123-45-67</div>
<div>Пн-Пт 9:00-18:00</div>
</div>
</li>
<li className="flex items-start gap-3">
<Mail className="w-5 h-5 mt-1 flex-shrink-0 text-blue-400" />
<a href="mailto:info@softclick.ru" className="hover:text-blue-400 transition-colors">
info@softclick.ru
</a>
</li>
<li className="flex items-start gap-3">
<MapPin className="w-5 h-5 mt-1 flex-shrink-0 text-blue-400" />
<div>
Москва, Пресненская наб., 12
</div>
</li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-gray-800 flex flex-col md:flex-row justify-between items-center gap-4">
<div>
&copy; 2025 SOFTCLICK. Все права защищены.
</div>
<div className="flex gap-6">
<a href="#" className="hover:text-blue-400 transition-colors">Политика конфиденциальности</a>
<a href="#" className="hover:text-blue-400 transition-colors">Условия использования</a>
</div>
</div>
</div>
</footer>
);
}

235
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,235 @@
import { Menu, X, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import { ContactModal } from './ContactModal';
interface HeaderProps {
onNavigate?: (page: string, section?: string) => void;
}
export function Header({ onNavigate }: HeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isSolutionsOpen, setIsSolutionsOpen] = useState(false);
const [isServicesOpen, setIsServicesOpen] = useState(false);
const [isContactModalOpen, setIsContactModalOpen] = useState(false);
const solutions = [
{ name: 'Облако', path: 'cloud' },
{ name: 'Российское ПО', path: 'russian-software' },
{ name: 'Кибербезопасность', path: 'cybersecurity' },
{ name: 'Решения на базе ИИ', path: 'ai-solutions' },
{ name: 'Российские ноутбуки, ПК и серверы', path: 'russian-hardware' },
{ name: 'Аппаратные решения', path: 'hardware-solutions' },
{ name: 'САПР и ГИС', path: 'cad-gis' },
{ name: 'Индустриальные решения', path: 'industrial-solutions' },
{ name: 'Для госсектора', path: 'government' },
{ name: 'Решения для бизнеса', path: 'business' },
];
const services = [
{ name: 'Консалтинг и проектирование', path: 'consulting' },
{ name: 'Поставка и внедрение решений', path: 'implementation' },
{ name: 'Управляемые сервисы', path: 'managed-services' },
{ name: 'Интеграция и разработка', path: 'integration' },
{ name: 'Специализированные услуги по отраслям', path: 'specialized-services' },
{ name: 'Обслуживание и сопровождение', path: 'support' },
{ name: 'Услуги по подписке', path: 'subscription' },
];
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, sectionId: string) => {
e.preventDefault();
console.log('Header: handleNavClick called with sectionId:', sectionId);
onNavigate?.('home', sectionId);
setIsMenuOpen(false);
};
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
<nav className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-12">
<a href="#" onClick={(e) => { e.preventDefault(); onNavigate?.('home'); }} className="flex items-center gap-3 group">
<span className="text-2xl text-gray-900 tracking-wide font-bold">SOFTCLICK</span>
</a>
<div className="hidden lg:flex items-center gap-8">
<div
className="relative"
onMouseEnter={() => setIsSolutionsOpen(true)}
onMouseLeave={() => setIsSolutionsOpen(false)}
>
<button className="flex items-center gap-1 text-gray-700 hover:text-blue-600 transition-colors py-2 whitespace-nowrap">
Решения
<ChevronDown className={`w-4 h-4 transition-transform ${isSolutionsOpen ? 'rotate-180' : ''}`} />
</button>
{isSolutionsOpen && (
<div className="absolute top-full left-0 pt-2">
<div className="w-80 bg-white rounded-xl shadow-xl border border-gray-100 py-3 animate-in fade-in slide-in-from-top-2 duration-200">
{solutions.map((solution) => (
<button
key={solution.path}
onClick={() => {
onNavigate?.(solution.path);
setIsSolutionsOpen(false);
}}
className="w-full text-left px-5 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
>
{solution.name}
</button>
))}
</div>
</div>
)}
</div>
<div
className="relative"
onMouseEnter={() => setIsServicesOpen(true)}
onMouseLeave={() => setIsServicesOpen(false)}
>
<button className="flex items-center gap-1 text-gray-700 hover:text-blue-600 transition-colors py-2 whitespace-nowrap">
Услуги
<ChevronDown className={`w-4 h-4 transition-transform ${isServicesOpen ? 'rotate-180' : ''}`} />
</button>
{isServicesOpen && (
<div className="absolute top-full left-0 pt-2">
<div className="w-80 bg-white rounded-xl shadow-xl border border-gray-100 py-3 animate-in fade-in slide-in-from-top-2 duration-200">
{services.map((service) => (
<button
key={service.path}
onClick={() => {
onNavigate?.(service.path);
setIsServicesOpen(false);
}}
className="w-full text-left px-5 py-3 text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors"
>
{service.name}
</button>
))}
</div>
</div>
)}
</div>
<a
href="#cases"
onClick={(e) => handleNavClick(e, 'cases')}
className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap"
>
Кейсы
</a>
<a
href="#about"
onClick={(e) => handleNavClick(e, 'about')}
className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap"
>
О компании
</a>
<a
href="#contact"
onClick={(e) => handleNavClick(e, 'contact')}
className="text-gray-700 hover:text-blue-600 transition-colors whitespace-nowrap"
>
Контакты
</a>
</div>
</div>
<div className="hidden lg:flex items-center gap-4">
<button className="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all shadow-sm hover:shadow-md" onClick={() => setIsContactModalOpen(true)}>
Связаться
</button>
</div>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="lg:hidden p-2"
>
{isMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{isMenuOpen && (
<div className="lg:hidden pt-4 pb-2">
<div className="flex flex-col gap-4">
<div>
<button
onClick={() => setIsSolutionsOpen(!isSolutionsOpen)}
className="text-gray-700 hover:text-blue-600 w-full text-left flex items-center justify-between"
>
Решения
<ChevronDown className={`w-4 h-4 transition-transform ${isSolutionsOpen ? 'rotate-180' : ''}`} />
</button>
{isSolutionsOpen && (
<div className="pl-4 mt-2 space-y-2">
{solutions.map((solution) => (
<button
key={solution.path}
onClick={() => {
onNavigate?.(solution.path);
setIsMenuOpen(false);
setIsSolutionsOpen(false);
}}
className="block w-full text-left text-sm text-gray-600 hover:text-blue-600 py-2"
>
{solution.name}
</button>
))}
</div>
)}
</div>
<div>
<button
onClick={() => setIsServicesOpen(!isServicesOpen)}
className="text-gray-700 hover:text-blue-600 w-full text-left flex items-center justify-between"
>
Услуги
<ChevronDown className={`w-4 h-4 transition-transform ${isSolutionsOpen ? 'rotate-180' : ''}`} />
</button>
{isServicesOpen && (
<div className="pl-4 mt-2 space-y-2">
{services.map((service) => (
<button
key={service.path}
onClick={() => {
onNavigate?.(service.path);
setIsMenuOpen(false);
setIsSolutionsOpen(false);
setIsServicesOpen(false);
}}
className="block w-full text-left text-sm text-gray-600 hover:text-blue-600 py-2"
>
{service.name}
</button>
))}
</div>
)}
</div>
<a
href="#cases"
onClick={(e) => handleNavClick(e, 'cases')}
className="text-gray-700 hover:text-blue-600"
>
Кейсы
</a>
<a
href="#about"
onClick={(e) => handleNavClick(e, 'about')}
className="text-gray-700 hover:text-blue-600"
>
О компании
</a>
<a
href="#contact"
onClick={(e) => handleNavClick(e, 'contact')}
className="text-gray-700 hover:text-blue-600"
>
Контакты
</a>
</div>
</div>
)}
</nav>
<ContactModal isOpen={isContactModalOpen} onClose={() => setIsContactModalOpen(false)} />
</header>
);
}

105
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { ArrowRight, Play, Cloud, Shield, Zap, Database, Code, Users } from 'lucide-react';
interface HeroProps {
onOpenContactModal?: () => void;
}
export function Hero({ onOpenContactModal }: HeroProps) {
return (
<section className="relative pt-32 pb-20 overflow-hidden bg-white">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<div className="grid lg:grid-cols-2 gap-16 items-center">
<div className="space-y-8">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-700 rounded-full border border-blue-100">
<span className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></span>
<span>Надежный партнер с 2021 года</span>
</div>
<h1 className="text-5xl lg:text-6xl text-gray-900 leading-tight">
IT-решения для роста вашего бизнеса
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
Проектируем, внедряем и сопровождаем корпоративные IT-системы.
Облачная инфраструктура, безопасность, автоматизация процессов.
</p>
<div className="flex flex-wrap gap-4">
<button className="group flex items-center gap-2 px-8 py-4 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-all" onClick={onOpenContactModal}>
Обсудить проект
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
<button className="flex items-center gap-2 px-8 py-4 bg-gray-50 text-gray-900 rounded-xl hover:bg-gray-100 transition-all">
Портфолио
</button>
</div>
<div className="grid grid-cols-3 gap-8 pt-8 border-t border-gray-100">
<div>
<div className="text-3xl text-gray-900 mb-1">300+</div>
<div className="text-gray-600">клиентов</div>
</div>
<div>
<div className="text-3xl text-gray-900 mb-1">50+</div>
<div className="text-gray-600">партнерств</div>
</div>
<div>
<div className="text-3xl text-gray-900 mb-1">95%</div>
<div className="text-gray-600">повторных обращений</div>
</div>
</div>
</div>
{/* Простая сетка иконок в стиле VK/Яндекс */}
<div className="relative">
<style>{`
@keyframes gentleFloat {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
}
.float-item {
animation: gentleFloat 6s ease-in-out infinite;
}
.float-item:nth-child(1) { animation-delay: 0s; }
.float-item:nth-child(2) { animation-delay: 0.5s; }
.float-item:nth-child(3) { animation-delay: 1s; }
.float-item:nth-child(4) { animation-delay: 1.5s; }
.float-item:nth-child(5) { animation-delay: 2s; }
.float-item:nth-child(6) { animation-delay: 2.5s; }
`}</style>
<div className="grid grid-cols-3 gap-6">
<div className="space-y-6">
<div className="float-item aspect-square bg-blue-50 rounded-3xl flex items-center justify-center group hover:bg-blue-100 transition-colors">
<Cloud className="w-12 h-12 text-blue-600" />
</div>
<div className="float-item aspect-square bg-gradient-to-br from-blue-500 to-blue-600 rounded-3xl flex items-center justify-center shadow-lg">
<Shield className="w-12 h-12 text-white" />
</div>
</div>
<div className="space-y-6 pt-12">
<div className="float-item aspect-square bg-violet-50 rounded-3xl flex items-center justify-center group hover:bg-violet-100 transition-colors">
<Database className="w-12 h-12 text-violet-600" />
</div>
<div className="float-item aspect-square bg-emerald-50 rounded-3xl flex items-center justify-center group hover:bg-emerald-100 transition-colors">
<Code className="w-12 h-12 text-emerald-600" />
</div>
</div>
<div className="space-y-6">
<div className="float-item aspect-square bg-orange-50 rounded-3xl flex items-center justify-center group hover:bg-orange-100 transition-colors">
<Zap className="w-12 h-12 text-orange-600" />
</div>
<div className="float-item aspect-square bg-cyan-50 rounded-3xl flex items-center justify-center group hover:bg-cyan-100 transition-colors">
<Users className="w-12 h-12 text-cyan-600" />
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

116
src/components/Services.tsx Normal file
View File

@@ -0,0 +1,116 @@
import { Cloud, Shield, Cpu, Database, Server, Settings } from 'lucide-react';
import { ImageWithFallback } from './figma/ImageWithFallback';
const services = [
{
icon: Cloud,
title: 'Облачные решения',
description: 'Миграция и управление инфраструктурой на базе Яндекс.Облако и VK Cloud',
gradient: 'from-blue-50 to-cyan-50',
iconBg: 'bg-white',
iconColor: 'text-blue-600'
},
{
icon: Shield,
title: 'Кибербезопасность',
description: 'Комплексная защита периметра и данных с решениями Kaspersky',
gradient: 'from-violet-50 to-purple-50',
iconBg: 'bg-white',
iconColor: 'text-violet-600'
},
{
icon: Cpu,
title: 'Цифровая трансформация',
description: 'Автоматизация процессов на платформах 1С и Галактика',
gradient: 'from-emerald-50 to-teal-50',
iconBg: 'bg-white',
iconColor: 'text-emerald-600'
},
{
icon: Database,
title: 'Управление данными',
description: 'Развертывание и поддержка СУБД Postgres Pro',
gradient: 'from-orange-50 to-amber-50',
iconBg: 'bg-white',
iconColor: 'text-orange-600'
},
{
icon: Server,
title: 'Серверная инфраструктура',
description: 'Поставка и настройка оборудования Kraftway',
gradient: 'from-pink-50 to-rose-50',
iconBg: 'bg-white',
iconColor: 'text-pink-600'
},
{
icon: Settings,
title: 'Системная интеграция',
description: 'Построение корпоративной ИТ-архитектуры на Astra Linux',
gradient: 'from-indigo-50 to-blue-50',
iconBg: 'bg-white',
iconColor: 'text-indigo-600'
}
];
export function Services() {
return (
<section id="services" className="py-24 bg-white">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<div className="max-w-3xl mb-16">
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">
Услуги
</p>
<h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">
Комплексные IT-решения для вашего бизнеса
</h2>
<p className="text-xl text-gray-600">
Полный цикл: от стратегии до технической поддержки
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{services.map((service, index) => {
const Icon = service.icon;
const floatClasses = [
'animate-float',
'animate-float-delay-1',
'animate-float-delay-2',
'animate-float-delay-3',
'animate-float-delay-4',
'animate-float-delay-5'
];
const floatClass = floatClasses[index % 6];
return (
<div
key={index}
className="group bg-white rounded-2xl border border-gray-100 hover:border-blue-200 hover:shadow-xl transition-all duration-300 overflow-hidden"
>
<div className={`relative h-48 overflow-hidden bg-gradient-to-br ${service.gradient} flex items-center justify-center`}>
<div className={`w-20 h-20 ${service.iconBg} rounded-2xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300 shadow-md ${floatClass}`}>
<Icon className={`w-10 h-10 ${service.iconColor}`} />
</div>
</div>
<div className="p-6">
<h3 className="text-xl text-gray-900 mb-3">
{service.title}
</h3>
<p className="text-gray-600 mb-4 leading-relaxed">
{service.description}
</p>
<button className="text-blue-600 hover:text-blue-700 transition-colors inline-flex items-center gap-2">
Подробнее
<span className="transition-all">&rarr;</span>
</button>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,92 @@
import { Building2, ShoppingCart, Factory, Heart, GraduationCap, Landmark } from 'lucide-react';
const solutions = [
{
icon: Building2,
title: 'Корпоративный сектор',
description: 'ERP, CRM, автоматизация документооборота',
projects: '1200+ проектов'
},
{
icon: ShoppingCart,
title: 'Ритейл и e-commerce',
description: 'Омниканальные платформы продаж',
projects: '850+ проектов'
},
{
icon: Factory,
title: 'Производство',
description: 'IoT, системы управления производством',
projects: '650+ проектов'
},
{
icon: Heart,
title: 'Здравоохранение',
description: 'Медицинские информационные системы',
projects: '420+ проектов'
},
{
icon: GraduationCap,
title: 'Образование',
description: 'Платформы дистанционного обучения',
projects: '380+ проектов'
},
{
icon: Landmark,
title: 'Государственный сектор',
description: 'Цифровизация госуслуг',
projects: '500+ проектов'
}
];
export function Solutions() {
return (
<section id="solutions" className="py-24 bg-gray-50">
<div className="max-w-[1600px] mx-auto px-6 xl:px-12 2xl:px-16">
<div className="max-w-3xl mb-16">
<p className="text-sm uppercase tracking-wider text-gray-500 mb-4">
Отраслевые решения
</p>
<h2 className="text-4xl lg:text-5xl text-gray-900 mb-6">
Решения под ключ для разных отраслей
</h2>
<p className="text-xl text-gray-600">
Глубокое понимание специфики бизнеса в каждой отрасли
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{solutions.map((solution, index) => {
const Icon = solution.icon;
return (
<div
key={index}
className="group p-8 bg-white rounded-2xl hover:shadow-xl border border-gray-100 hover:border-blue-100 transition-all duration-300 cursor-pointer"
>
<div className="flex items-start gap-4 mb-4">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform shadow-sm">
<Icon className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="text-lg text-gray-900 mb-2">
{solution.title}
</h3>
<p className="text-gray-600">
{solution.description}
</p>
</div>
</div>
<div className="pt-4 border-t border-gray-100">
<span className="text-sm text-blue-600">
{solution.projects}
</span>
</div>
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,27 @@
import React, { useState } from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const { src, alt, style, className, ...rest } = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
)
}

View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
import { ChevronDownIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,11 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
import { cn } from "./utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { ChevronRight, MoreHorizontal } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react@0.487.0";
import { DayPicker } from "react-day-picker@8.10.1";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "./utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,241 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react@8.6.0";
import { ArrowLeft, ArrowRight } from "lucide-react@0.487.0";
import { cn } from "./utils";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

353
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,353 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts@2.15.2";
import { cn } from "./utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox@1.1.4";
import { CheckIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible@1.1.3";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk@1.1.1";
import { SearchIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu@2.2.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog@1.1.6";
import { XIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul@1.1.2";
import { cn } from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

168
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form@7.55.0";
import { cn } from "./utils";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card@1.1.6";
import { cn } from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp@1.4.2";
import { MinusIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "./utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
import { cn } from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,276 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar@1.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
import { cva } from "class-variance-authority@0.7.1";
import { ChevronDownIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react@0.487.0";
import { cn } from "./utils";
import { Button, buttonVariants } from "./button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover@1.1.6";
import { cn } from "./utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress@1.1.2";
import { cn } from "./utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group@1.2.3";
import { CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react@0.487.0";
import * as ResizablePrimitive from "react-resizable-panels@2.1.7";
import { cn } from "./utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area@1.2.3";
import { cn } from "./utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,189 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select@2.1.6";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "lucide-react@0.487.0";
import { cn } from "./utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator@1.1.2";
import { cn } from "./utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

139
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog@1.1.6";
import { XIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,726 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { VariantProps, cva } from "class-variance-authority@0.7.1";
import { PanelLeftIcon } from "lucide-react@0.487.0";
import { useIsMobile } from "./use-mobile";
import { cn } from "./utils";
import { Button } from "./button";
import { Input } from "./input";
import { Separator } from "./separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./sheet";
import { Skeleton } from "./skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,13 @@
import { cn } from "./utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider@1.2.3";
import { cn } from "./utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -0,0 +1,25 @@
"use client";
import { useTheme } from "next-themes@0.4.6";
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch@1.1.3";
import { cn } from "./utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { cn } from "./utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs@1.1.3";
import { cn } from "./utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "./utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group@1.1.2";
import { type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
import { toggleVariants } from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip@1.1.8";
import { cn } from "./utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,95 @@
# Деплой на традиционный хостинг
## Для любого хостинга с FTP/SSH доступом
### Шаг 1: Локальная сборка
```bash
npm install
npm run build
```
### Шаг 2: Загрузка файлов
После сборки появится папка `dist/` - её содержимое нужно загрузить на хостинг.
**Через FTP:**
1. Подключитесь к хостингу через FileZilla/WinSCP
2. Перейдите в папку `public_html` или `www`
3. Загрузите ВСЁ содержимое папки `dist/` (не саму папку!)
**Через SSH:**
```bash
# Соберите локально
npm run build
# Загрузите через rsync
rsync -avz --delete dist/ user@your-server.ru:/var/www/html/
```
### Шаг 3: Настройка .htaccess (для Apache)
Создайте в корне сайта файл `.htaccess`:
```apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
# Кеширование статических файлов
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
# Сжатие
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript
</IfModule>
```
### Шаг 4: Настройка для Nginx
Если на сервере Nginx, добавьте в конфигурацию сайта:
```nginx
server {
listen 80;
server_name your-domain.ru www.your-domain.ru;
root /var/www/html;
index index.html;
# Сжатие
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# SPA роутинг
location / {
try_files $uri $uri/ /index.html;
}
# Кеширование статики
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
## Рекомендуемые российские хостинги:
1. **Timeweb** - от 169 ₽/мес, отличная поддержка
2. **Beget** - от 200 ₽/мес, очень стабильный
3. **Reg.ru** - от 120 ₽/мес, простой интерфейс
4. **HostiMan** - от 150 ₽/мес
5. **SpaceWeb** - от 150 ₽/мес
Все эти хостинги поддерживают Node.js и SSH доступ.

View File

@@ -0,0 +1,45 @@
# Деплой на Яндекс.Облако
## Шаг 1: Подготовка
1. Зарегистрируйтесь на https://cloud.yandex.ru
2. Установите Yandex Cloud CLI: https://cloud.yandex.ru/docs/cli/quickstart
## Шаг 2: Создание бакета Object Storage
```bash
# Создайте бакет для статического сайта
yc storage bucket create --name softclick-website --public-read
# Настройте бакет как статический сайт
yc storage bucket update --name softclick-website \
--website-settings '{"index":"index.html","error":"index.html"}'
```
## Шаг 3: Сборка и загрузка
```bash
# Соберите проект
npm run build
# Загрузите файлы в бакет
yc storage s3api put-object \
--bucket softclick-website \
--key dist/ \
--body ./dist/
```
## Шаг 4: Настройка CDN (опционально)
1. Перейдите в Cloud CDN
2. Создайте CDN-ресурс
3. Укажите источник: ваш бакет Object Storage
4. Настройте кастомный домен
## Автоматизация через GitHub Actions
Создайте секреты в GitHub:
- YC_SERVICE_ACCOUNT_KEY_JSON
- YC_BUCKET_NAME
Деплой будет происходить автоматически при пуше в main.
## Стоимость
- Object Storage: ~0.40 ₽/ГБ/месяц
- Трафик CDN: ~0.85 ₽/ГБ
- Для небольшого сайта: ~50-200 ₽/месяц

76
src/deploy.bat Normal file
View File

@@ -0,0 +1,76 @@
@echo off
chcp 65001 >nul
echo.
echo ====================================
echo 🚀 ДЕПЛОЙ SOFTCLICK
echo ====================================
echo.
echo 📦 Проверка зависимостей...
if not exist "node_modules\" (
echo Установка зависимостей...
call npm install
)
echo.
echo 🔨 Сборка проекта...
call npm run build
if %errorlevel% equ 0 (
echo.
echo ✅ Сборка успешна!
echo.
echo ====================================
echo 📂 Файлы готовы в папке: dist\
echo ====================================
echo.
echo Выберите действие:
echo 1. Открыть папку dist
echo 2. Создать архив dist.zip
echo 3. Показать инструкции по деплою
echo.
set /p choice="Ваш выбор (1-3): "
if "%choice%"=="1" (
explorer dist
)
if "%choice%"=="2" (
echo.
echo 📦 Создание архива...
powershell Compress-Archive -Path dist\* -DestinationPath dist.zip -Force
echo ✅ Архив создан: dist.zip
explorer .
)
if "%choice%"=="3" (
echo.
type DEPLOYMENT.md
)
echo.
echo ====================================
echo 📖 СПОСОБЫ ДЕПЛОЯ:
echo ====================================
echo.
echo ⭐ РЕКОМЕНДУЕМЫЕ (бесплатно):
echo 1. Netlify - https://netlify.com
echo 2. Cloudflare Pages - https://pages.cloudflare.com
echo 3. GitHub Pages - автоматически
echo.
echo 🇷🇺 РОССИЙСКИЕ:
echo 1. Яндекс.Облако - см. deploy-yandex-cloud.md
echo 2. Timeweb/Beget - см. deploy-traditional-hosting.md
echo.
echo 📄 Полная инструкция: DEPLOYMENT.md
echo.
) else (
echo.
echo ❌ Ошибка при сборке!
echo Проверьте логи выше.
pause
exit /b 1
)
pause

63
src/deploy.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Скрипт для деплоя SOFTCLICK сайта
# Использование: ./deploy.sh
echo "🚀 Начинаем деплой SOFTCLICK..."
# Проверка зависимостей
echo "📦 Проверка зависимостей..."
if [ ! -d "node_modules" ]; then
echo "Установка зависимостей..."
npm install
fi
# Сборка проекта
echo "🔨 Сборка проекта..."
npm run build
if [ $? -eq 0 ]; then
echo "✅ Сборка успешна!"
echo ""
echo "📂 Файлы для деплоя находятся в папке: ./dist/"
echo ""
echo "Выберите способ деплоя:"
echo "1) Скопировать путь к папке dist (для ручной загрузки)"
echo "2) Открыть папку dist в файловом менеджере"
echo "3) Создать архив dist.zip для загрузки"
echo ""
read -p "Ваш выбор (1-3): " choice
case $choice in
1)
echo "📁 Путь к папке dist:"
echo "$(pwd)/dist"
;;
2)
# Открыть папку в зависимости от ОС
if [[ "$OSTYPE" == "darwin"* ]]; then
open dist
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
xdg-open dist
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
explorer dist
fi
;;
3)
echo "📦 Создание архива..."
cd dist && zip -r ../dist.zip . && cd ..
echo "✅ Архив создан: dist.zip"
;;
esac
echo ""
echo "📖 Инструкции по деплою:"
echo " - Netlify: см. netlify.toml"
echo " - GitHub Pages: см. .github/workflows/deploy.yml"
echo " - Яндекс.Облако: см. deploy-yandex-cloud.md"
echo " - Хостинг: см. deploy-traditional-hosting.md"
else
echo "❌ Ошибка при сборке!"
exit 1
fi

View File

@@ -0,0 +1,61 @@
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->

8
src/index.css Normal file
View File

@@ -0,0 +1,8 @@
@import 'tailwindcss' source(none);
@source './**/*.{js,ts,jsx,tsx}';
@import 'tw-animate-css';
@import './styles/globals.css';
@layer base {
html, body, #root { min-height: 100vh; height: 100%; margin: 0; }
}

14
src/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="SOFTCLICK - российская IT-компания, специализирующаяся на импортозамещении и внедрении отечественных решений" />
<title>SOFTCLICK - Российские IT-решения с 2021 года</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
import './styles/animations.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

11
src/netlify.toml Normal file
View File

@@ -0,0 +1,11 @@
[build]
command = "npm run build"
publish = "build"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[build.environment]
NODE_VERSION = "18"

1850
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
src/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "softclick-corporate-website",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=18.0.0 <22.0.0",
"npm": ">=9.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^0.344.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1"
},
"devDependencies": {
"@types/node": "^20.11.19",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^4.0.0",
"typescript": "^5.2.2",
"vite": "^5.1.4"
}
}

View File

@@ -0,0 +1,64 @@
import { CategoryPage } from '../components/CategoryPage';
interface AISolutionsPageProps {
onBack: () => void;
}
export function AISolutionsPage({ onBack }: AISolutionsPageProps) {
const vendors = [
{
name: 'Яндекс AI',
description: 'Платформа машинного обучения и искусственного интеллекта от Яндекса.',
products: ['YandexGPT', 'SpeechKit', 'Vision', 'Translate']
},
{
name: 'Сбер AI',
description: 'Экосистема AI-решений Сбера для бизнеса и разработчиков.',
products: ['GigaChat', 'SaluteSpeech', 'Vision', 'SmartSearch']
},
{
name: 'VK AI',
description: 'AI-платформа VK с решениями для распознавания, синтеза и анализа.',
products: ['Маруся', 'Vision API', 'ML Platform', 'Рекомендательные системы']
},
{
name: 'АйТеко AI',
description: 'Российские решения на базе искусственного интеллекта для промышленности.',
products: ['Компьютерное зрение', 'Прогнозная аналитика', 'NLP']
},
{
name: 'Cognitive Technologies',
description: 'Разработчик систем компьютерного зрения и автономного транспорта.',
products: ['C-Pilot', 'ALPR', 'Видеоаналитика']
},
{
name: 'NTechLab',
description: 'Технологии распознавания лиц и видеоаналитики на базе AI.',
products: ['FindFace', 'FindFace Multi', 'VideoAnalytics']
},
{
name: 'Neuro.net',
description: 'Платформа для создания и внедрения AI-решений в бизнес-процессы.',
products: ['ML Studio', 'Чат-боты', 'Предиктивная аналитика']
},
{
name: 'Just AI',
description: 'Платформа для создания голосовых ассистентов и чат-ботов.',
products: ['JAICP', 'Conversational AI', 'Voice AI']
},
{
name: 'ЦРТ',
description: 'Центр речевых технологий - решения для распознавания и синтеза речи.',
products: ['ASR', 'TTS', 'Голосовая биометрия', 'Диктофон']
}
];
return (
<CategoryPage
title="Решения на базе ИИ"
description="Передовые технологии искусственного интеллекта и машинного обучения для автоматизации бизнес-процессов"
vendors={vendors}
onBack={onBack}
/>
);
}

View File

@@ -0,0 +1,64 @@
import { CategoryPage } from '../components/CategoryPage';
interface BusinessPageProps {
onBack: () => void;
}
export function BusinessPage({ onBack }: BusinessPageProps) {
const vendors = [
{
name: 'SAP',
description: 'Мировой лидер в области корпоративных приложений для управления бизнесом.',
products: ['SAP S/4HANA', 'SAP SuccessFactors', 'SAP Ariba']
},
{
name: '1С',
description: 'Комплексные решения для автоматизации бизнес-процессов российских компаний.',
products: ['1С:ERP', '1С:CRM', '1С:Документооборот', '1С:WMS']
},
{
name: 'Битрикс24',
description: 'Единая платформа для управления бизнесом, CRM и коммуникаций.',
products: ['CRM', 'Задачи и проекты', 'Сайты', 'Интернет-магазин']
},
{
name: 'ELMA365',
description: 'Low-code платформа для цифровой трансформации бизнеса.',
products: ['BPM', 'CRM', 'ECM', 'Low-code разработка']
},
{
name: 'Мегаплан',
description: 'Система управления проектами и CRM для малого и среднего бизнеса.',
products: ['CRM', 'Управление задачами', 'Отчеты']
},
{
name: 'amoCRM',
description: 'CRM-система для автоматизации продаж и работы с клиентами.',
products: ['Воронка продаж', 'Мессенджеры', 'IP-телефония']
},
{
name: 'Terrsoft',
description: 'Low-code платформа Creatio для автоматизации бизнес-процессов.',
products: ['Creatio CRM', 'Creatio Marketing', 'BPM']
},
{
name: 'Мой Склад',
description: 'Облачный сервис для управления торговлей и складом.',
products: ['Складской учет', 'Интернет-магазин', 'Товароучет']
},
{
name: 'Контур',
description: 'Экосистема сервисов для автоматизации бухгалтерии и бизнес-процессов.',
products: ['Контур.Бухгалтерия', 'Контур.Диадок', 'Контур.Экстерн']
}
];
return (
<CategoryPage
title="Решения для бизнеса"
description="Комплексные IT-решения для автоматизации бизнес-процессов, CRM, ERP и управления предприятием"
vendors={vendors}
onBack={onBack}
/>
);
}

View File

@@ -0,0 +1,64 @@
import { CategoryPage } from '../components/CategoryPage';
interface CADandGISPageProps {
onBack: () => void;
}
export function CADandGISPage({ onBack }: CADandGISPageProps) {
const vendors = [
{
name: 'АСКОН',
description: 'Крупнейший российский разработчик инженерного ПО и САПР систем.',
products: ['КОМПАС-3D', 'КОМПАС-График', 'Renga', 'Pilot-ICE']
},
{
name: 'Nanosoft',
description: 'Разработка САПР и ГИС решений для проектирования и строительства.',
products: ['nanoCAD', 'GeoniCS', 'ТОПОПЛАН']
},
{
name: 'ГИС Панорама',
description: 'Российская ГИС платформа для работы с пространственными данными.',
products: ['ГИС Карта 2011', 'GIS WebServer', 'Панорама АГРО']
},
{
name: 'NextGIS',
description: 'Облачная ГИС платформа и геопространственные решения.',
products: ['NextGIS Web', 'NextGIS Mobile', 'NextGIS QGIS']
},
{
name: 'ZWSOFT',
description: 'Универсальная САПР платформа для 2D и 3D проектирования.',
products: ['ZWCAD', 'ZW3D']
},
{
name: 'ЛОЦМАН',
description: 'PLM и САПР решения для управления жизненным циклом изделий.',
products: ['ЛОЦМАН:PLM', 'ЛОЦМАН:CAD']
},
{
name: 'CGS Labs',
description: 'Разработка систем инженерного анализа и моделирования.',
products: ['CGS Модельер', 'CGS Эксперт']
},
{
name: 'CADLib',
description: 'Библиотеки и компоненты для разработки САПР приложений.',
products: ['Библиотеки DWG', 'Форматы CAD']
},
{
name: 'IndorSoft',
description: 'САПР для проектирования автомобильных дорог и инфраструктуры.',
products: ['IndorCAD', 'IndorRoad', 'IndorTrafficPlan']
}
];
return (
<CategoryPage
title="САПР и ГИС"
description="Системы автоматизированного проектирования и геоинформационные системы для инженеров, архитекторов и проектировщиков"
vendors={vendors}
onBack={onBack}
/>
);
}

49
src/pages/CloudPage.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { CategoryPage } from '../components/CategoryPage';
interface CloudPageProps {
onBack: () => void;
}
export function CloudPage({ onBack }: CloudPageProps) {
const vendors = [
{
name: 'Яндекс.Облако',
description: 'Полный спектр облачных сервисов: вычисления, хранение данных, машинное обучение, базы данных.',
products: ['Compute Cloud', 'Object Storage', 'Managed Kubernetes', 'DataLens']
},
{
name: 'VK Cloud',
description: 'Облачная платформа VK с IaaS, PaaS и SaaS решениями для бизнеса любого масштаба.',
products: ['Облачные серверы', 'Kubernetes', 'CDN', 'ML Platform']
},
{
name: 'Cloud.ru',
description: 'Российская мультиоблачная платформа от Ростелекома с высоким уровнем безопасности.',
products: ['IaaS', 'PaaS', 'DBaaS', 'Backup']
},
{
name: 'SberCloud',
description: 'Облачные решения от Сбера для корпоративных клиентов с интеграцией AI и ML.',
products: ['Виртуальные машины', 'Kubernetes', 'AI Services', 'CDN']
},
{
name: 'МТС Cloud',
description: 'Облачная инфраструктура МТС с широким спектром сервисов и российской локализацией.',
products: ['Cloud Server', 'Object Storage', 'Big Data', 'IoT']
},
{
name: 'Selectel',
description: 'Один из крупнейших провайдеров облачных услуг в России с собственными ЦОД.',
products: ['Облачная платформа', 'Dedicated серверы', 'CDN', 'Storage']
}
];
return (
<CategoryPage
title="Облачные решения"
description="Российские облачные платформы для размещения инфраструктуры, приложений и данных с соблюдением требований законодательства РФ"
vendors={vendors}
onBack={onBack}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { CategoryPage } from '../components/CategoryPage';
interface ConsultingPageProps {
onBack: () => void;
}
export function ConsultingPage({ onBack }: ConsultingPageProps) {
const services = [
{
name: 'IT-стратегия и архитектура',
description: 'Разработка IT-стратегии, целевой архитектуры и дорожной карты цифровой трансформации с учетом специфики российского рынка.',
products: ['IT-аудит', 'Архитектура решений', 'Стратегия']
},
{
name: 'Миграция на российское ПО',
description: 'Комплексный анализ текущей инфраструктуры и разработка плана перехода на отечественные решения с минимальными рисками.',
products: ['Аудит', 'План миграции', 'Управление рисками']
},
{
name: 'Оптимизация IT-инфраструктуры',
description: 'Анализ и оптимизация существующей инфраструктуры для повышения эффективности и снижения затрат.',
products: ['Аудит инфраструктуры', 'Оптимизация', 'TCO анализ']
},
{
name: 'Проектирование ЦОД',
description: 'Разработка концепции и детальное проектирование центров обработки данных на базе российского оборудования.',
products: ['Концепция ЦОД', 'Техническое задание', 'ПОС']
},
{
name: 'Импортозамещение',
description: 'Разработка стратегии замены зарубежных решений на российские аналоги с сохранением функциональности.',
products: ['Анализ решений', 'Подбор аналогов', 'План внедрения']
},
{
name: 'Бизнес-анализ и процессы',
description: 'Анализ бизнес-процессов и разработка требований к информационным системам для их автоматизации.',
products: ['Анализ процессов', 'Требования', 'BPMN']
}
];
return (
<CategoryPage
title="Консалтинг и проектирование"
description="Экспертный консалтинг по построению и трансформации IT-инфраструктуры. Помогаем разработать стратегию цифровизации, выбрать оптимальные технологии и спланировать миграцию на российские решения."
vendors={services}
onBack={onBack}
/>
);
}

View File

@@ -0,0 +1,64 @@
import { CategoryPage } from '../components/CategoryPage';
interface CybersecurityPageProps {
onBack: () => void;
}
export function CybersecurityPage({ onBack }: CybersecurityPageProps) {
const vendors = [
{
name: 'Лаборатория Касперского',
description: 'Мировой лидер в области кибербезопасности с комплексными решениями защиты.',
products: ['Kaspersky Endpoint Security', 'Kaspersky Security Center', 'Anti-Targeted Attack']
},
{
name: 'Positive Technologies',
description: 'Разработчик решений по информационной безопасности и защите от киберугроз.',
products: ['MaxPatrol', 'PT Network Attack Discovery', 'PT Application Inspector']
},
{
name: 'InfoWatch',
description: 'Российский разработчик систем защиты от утечек конфиденциальной информации.',
products: ['InfoWatch Traffic Monitor', 'InfoWatch Device Monitor', 'Appercut']
},
{
name: 'SearchInform',
description: 'Системы информационной безопасности и предотвращения утечек данных.',
products: ['SearchInform DLP', 'FileAuditor', 'SIEM']
},
{
name: 'Гарда',
description: 'Решения для защиты периметра, веб-приложений и управления доступом.',
products: ['Гарда WAF', 'Гарда VPN', 'Гарда Firewall', 'Гарда БД']
},
{
name: 'DeviceLock',
description: 'Контроль внешних устройств и каналов передачи данных для защиты от утечек.',
products: ['DeviceLock DLP', 'DeviceLock Enterprise Server', 'ContentLock']
},
{
name: 'КриптоПро',
description: 'Средства криптографической защиты информации и электронной подписи.',
products: ['КриптоПро CSP', 'КриптоПро ЭЦП Browser', 'КриптоПро JCP']
},
{
name: 'Код Безопасности',
description: 'Российский вендор решений по информационной безопасности.',
products: ['Secret Net Studio', 'Dallas Lock', 'Континент']
},
{
name: 'Аладдин Р.Д.',
description: 'Разработчик средств аутентификации и защиты информации.',
products: ['JaCarta', 'eToken', 'Аладдин Office Security']
}
];
return (
<CategoryPage
title="Кибербезопасность"
description="Комплексные решения для защиты корпоративной инфраструктуры, данных и приложений от современных киберугроз"
vendors={vendors}
onBack={onBack}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More