Переписаны модули, тесты, обработка ошибок
This commit is contained in:
@@ -1,166 +1,91 @@
|
|||||||
# ✨ Nashel — Frontend
|
# ✨ Nashel — Frontend
|
||||||
|
|
||||||
> **Next.js 15 · TypeScript · React · TailwindCSS · Shadcn/UI**
|
> **Next.js 15 (App Router) · TypeScript · Tailwind CSS · Shadcn/UI · Zustand**
|
||||||
|
|
||||||
Клиентская часть платформы **Nashel** — маркетплейса для поиска и найма профессиональных исполнителей. Реализован как современное SPA/SSR приложение с тёмной темой, интерактивной картой и богатым UX.
|
Клиентская часть платформы **Nashel** — современного маркетплейса для поиска и найма профессиональных исполнителей. Приложение спроектировано как высокопроизводительное SPA/SSR решение с глубокой проработкой пользовательского опыта, адаптивным дизайном и интерактивными картами.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Какую проблему решает Nashel
|
## 🎯 Преимущества для пользователя
|
||||||
|
|
||||||
Клиенты тратят часы на поиск надёжных мастеров. Исполнители теряют заказы из-за отсутствия нормального онлайн-присутствия.
|
**Клиент:**
|
||||||
|
- **Мгновенный поиск:** Находите мастеров прямо на карте в своем районе.
|
||||||
|
- **Инфо-карты:** Подробные страницы услуг с галереями работ, честными ценами и атрибутами.
|
||||||
|
- **Безопасный заказ:** Оформляйте сделки и отслеживайте их статус в реальном времени.
|
||||||
|
|
||||||
**Nashel** соединяет их мгновенно:
|
**Исполнитель:**
|
||||||
- Ищи мастера прямо на карте рядом с домом
|
- **Цифровой кабинет:** Удобное управление услугами, расписанием и статусом доступности.
|
||||||
- Смотри фото работ, цены и описание услуги
|
- **Галереи работ:** Загрузка и управление фотографиями с поддержкой Drag-and-Drop.
|
||||||
- Нажми одну кнопку — и выйди на связь с исполнителем
|
- **Система рейтинга:** Накапливайте отзывы и повышайте доверие клиентов.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 👤 Роли пользователей
|
## 🛠️ Технологический стек
|
||||||
|
|
||||||
| Роль | Возможности |
|
|
||||||
|------|-------------|
|
|
||||||
| **Клиент** | Поиск услуг, просмотр профилей, карта |
|
|
||||||
| **Кандидат в мастера** | Личный кабинет, создание профиля исполнителя |
|
|
||||||
| **Мастер** | Полный кабинет: управление услугами, расписание, статус |
|
|
||||||
| **Компания** | Расширенный кабинет с представлением от юрлица |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗂️ Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # Страницы (Next.js App Router)
|
|
||||||
│ ├── (auth)/ # Регистрация, вход
|
|
||||||
│ ├── dashboard/ # Личный кабинет исполнителя
|
|
||||||
│ │ ├── offers/ # Управление услугами: список, создание, редактирование
|
|
||||||
│ │ ├── profile/ # Профиль, аватар, локация, расписание, компетенции
|
|
||||||
│ │ └── settings/ # Настройки аккаунта
|
|
||||||
│ ├── offers/[id]/ # Публичная страница просмотра услуги
|
|
||||||
│ ├── search/ # Поиск услуг + карта
|
|
||||||
│ └── layout.tsx # Общий layout с навигацией
|
|
||||||
│
|
|
||||||
├── components/ # Переиспользуемые компоненты
|
|
||||||
│ ├── ui/ # Shadcn UI компоненты (Button, Card, Dialog и т.д.)
|
|
||||||
│ ├── ImageUploader/ # Компонент загрузки, перетаскивания и удаления фото
|
|
||||||
│ └── ...
|
|
||||||
│
|
|
||||||
└── shared/
|
|
||||||
├── api/ # Функции для обращения к API (catalog, search, auth)
|
|
||||||
└── store/ # Zustand: геолокация пользователя
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Что реализовано
|
|
||||||
|
|
||||||
### Аутентификация
|
|
||||||
- Регистрация и вход (JWT в localStorage)
|
|
||||||
- Защищённые маршруты
|
|
||||||
- Сохранение сессии
|
|
||||||
|
|
||||||
### Карта и поиск
|
|
||||||
- Интерактивная карта на `React Leaflet` (без SSR)
|
|
||||||
- Поиск по **названию услуги**, **описанию** и **компетенциям**
|
|
||||||
- Результаты поиска появляются по мере ввода (от 2 символов)
|
|
||||||
- Маркеры исполнителей на карте с всплывающими подсказками
|
|
||||||
- Отображение расстояния до исполнителя (м/км)
|
|
||||||
- Сортировка: «Идеально подходят», «Ближайшие», «Лучший рейтинг»
|
|
||||||
- Фильтры поиска: по названию, описанию, компетенциям
|
|
||||||
- Отображение услуги **приоритетнее** исполнителя (если нашлась услуга — исполнитель отдельно не показывается)
|
|
||||||
|
|
||||||
### Карточка услуги в поиске
|
|
||||||
- Большое фото с hover-анимацией (scale + fade)
|
|
||||||
- Цена в рублях (₽), расстояние с иконкой
|
|
||||||
- Краткое описание услуги
|
|
||||||
- Роль и статус исполнителя (Мастер / Кандидат в мастера / Компания)
|
|
||||||
- Кнопка «Просмотреть услугу» — открывает страницу в новой вкладке
|
|
||||||
|
|
||||||
### Публичная страница услуги (`/offers/[id]`)
|
|
||||||
- Большой блок с фото-галереей (карусель):
|
|
||||||
- Навигация мышью (стрелки видны при наведении)
|
|
||||||
- Навигация клавишами ← →
|
|
||||||
- Счётчик фото (1/N)
|
|
||||||
- Превью-ряд внизу (миниатюры) с кольцевой подсветкой активного
|
|
||||||
- Блок описания с аккуратным заголовком
|
|
||||||
- Блок характеристик услуги (атрибуты ключ-значение) — под описанием
|
|
||||||
- Правая колонка: карточка исполнителя (имя, роль, рейтинг, документы)
|
|
||||||
- Блок «Готовы заказать?» с кнопкой «Связаться с исполнителем»
|
|
||||||
- Блок похожих услуг из категории (заглушка, готовится к наполнению)
|
|
||||||
- Адаптивная верстка: мобильная → десктопная
|
|
||||||
|
|
||||||
### Профиль исполнителя (личный кабинет)
|
|
||||||
- Загрузка аватара (превью + сохранение)
|
|
||||||
- Редактирование описания, компетенций (теги с поиском)
|
|
||||||
- Установка рабочего расписания по дням недели
|
|
||||||
- Переход в роль исполнителя
|
|
||||||
- Смена пароля
|
|
||||||
|
|
||||||
### Управление услугами
|
|
||||||
- Список моих услуг с фото-обложкой
|
|
||||||
- Создание и редактирование услуги:
|
|
||||||
- **Блок загрузки фото** (первым, до 10 шт., каждое ≤ 5 МБ)
|
|
||||||
- Drag-and-drop для перестановки фото мышью
|
|
||||||
- Кнопка удаления под каждым фото
|
|
||||||
- Первое фото = обложка услуги
|
|
||||||
- Название, описание, цена (тип + сумма), атрибуты
|
|
||||||
- Приостановка услуги (пауза)
|
|
||||||
- Удаление услуги (soft delete)
|
|
||||||
- Приостановленные и удалённые услуги не видны в поиске
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd nashel-frontend
|
|
||||||
|
|
||||||
# Установить зависимости
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Запустить dev-сервер
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Приложение доступно по адресу: `http://localhost:3000`
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
Создать файл `.env.local`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Технологии
|
|
||||||
|
|
||||||
| Технология | Назначение |
|
| Технология | Назначение |
|
||||||
|------------|------------|
|
|------------|------------|
|
||||||
| **Next.js 15** (App Router) | SSR/SPA, маршрутизация |
|
| **Next.js 15** | Фреймворк для SSR, маршрутизации и SEO-оптимизации |
|
||||||
| **TypeScript** | Типизация |
|
| **Tailwind CSS** | Адаптивная верстка и дизайн-система |
|
||||||
| **Tailwind CSS** | Стилизация |
|
| **Shadcn/UI** | Высококачественные доступные компоненты интерфейса |
|
||||||
| **Shadcn/UI** | Готовые UI компоненты |
|
| **Zustand** | Глобальное состояние (геолокация, сессии) |
|
||||||
| **Zustand** | Глобальное состояние (геолокация) |
|
| **React Leaflet** | Интерактивная карта (с динамической подгрузкой) |
|
||||||
| **React Leaflet** | Карта (с отключённым SSR) |
|
| **Axios** | Интеграция с API (авторизация, заказы, репутация) |
|
||||||
| **React Hook Form + Zod** | Формы и валидация |
|
| **React Hook Form** | Комплексная валидация форм (Zod) |
|
||||||
| **Axios** | HTTP-запросы к API |
|
|
||||||
| **Lucide React** | Иконки |
|
---
|
||||||
| **Sonner** | Тосты/уведомления |
|
|
||||||
|
## ✅ Текущая реализация
|
||||||
|
|
||||||
|
### 1. Поиск и Интерактивность
|
||||||
|
- **Карта:** Отображение маркеров исполнителей, расчет дистанции до пользователя в реальном времени.
|
||||||
|
- **Smart Search:** Поиск по заголовкам, описаниям и компетенциям с мгновенной фильтрацией.
|
||||||
|
- **Сортировка:** По расстоянию, релевантности и качеству предложений.
|
||||||
|
|
||||||
|
### 2. Страница Услуги (Offer Page)
|
||||||
|
- **Rich Gallery:**
|
||||||
|
- Продвинутая карусель с поддержкой клавиатуры (← →).
|
||||||
|
- Превью-ряд с подсветкой активного фото.
|
||||||
|
- Hover-эффект увеличения и плавные переходы.
|
||||||
|
- **Детализация:** Полный список атрибутов услуги, карточка исполнителя, статус верификации.
|
||||||
|
|
||||||
|
### 3. Личный кабинет (Dashboard)
|
||||||
|
- **Управление Офферами:**
|
||||||
|
- Создание услуг с мультизагрузкой фото (до 10 шт).
|
||||||
|
- Интерактивный порядок фото (Drag-and-Drop).
|
||||||
|
- Пауза и мягкое удаление услуг.
|
||||||
|
- **Профиль:** Смена аватара, настройка сложных расписаний, управление компетенциями.
|
||||||
|
- **Orders API:** Полная интеграция с бэкенд-модулем заказов (принятие, завершение, споры).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
1. Перейдите в директорию фронтенда:
|
||||||
|
```bash
|
||||||
|
cd nashel-frontend
|
||||||
|
```
|
||||||
|
2. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. Создайте `.env.local`:
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:5000
|
||||||
|
```
|
||||||
|
4. Запустите в режиме разработки:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу `http://localhost:3000`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔮 Планы развития
|
## 🔮 Планы развития
|
||||||
|
|
||||||
- [ ] Реальный чат с исполнителем (WebSocket)
|
- [ ] **Real-time Chat:** Чат между клиентом и мастером на WebSockets.
|
||||||
- [ ] Страница профиля исполнителя (публичная)
|
- [ ] **Public Profiles:** Публичные страницы мастеров с портфолио и отзывами.
|
||||||
- [ ] Система отзывов и рейтингов (реальные данные)
|
- [ ] **SEO Boost:** Гидратация мета-тегов для индексации поисковиками.
|
||||||
- [ ] Оформление заказа через платформу
|
- [ ] **Push Notifications:** Уведомления о новых заказах и сообщениях.
|
||||||
- [ ] Push-уведомления
|
- [ ] **Mobile App:** Разработка нативного приложения на React Native.
|
||||||
- [ ] Мобильное приложение (React Native)
|
- [ ] **Verification UI:** Интерфейс загрузки документов для подтверждения статуса.
|
||||||
- [ ] Полнотекстовый фильтр по категориям и тегам
|
|
||||||
- [ ] Похожие услуги (реальные данные из той же категории)
|
|
||||||
- [ ] SEO-оптимизация публичных страниц
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
|
|
||||||
> nashel-frontend@0.1.0 build
|
|
||||||
> next build
|
|
||||||
|
|
||||||
▲ Next.js 15.1.0
|
|
||||||
- Environments: .env.local
|
|
||||||
|
|
||||||
Creating an optimized production build ...
|
|
||||||
node.exe : Failed to compile.
|
|
||||||
C:\Program Files\nodejs\npm.ps1:29 знак:3
|
|
||||||
+ & $NODE_EXE $NPM_CLI_JS $args
|
|
||||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
+ CategoryInfo : NotSpecified: (Failed to compile.:String) [], RemoteException
|
|
||||||
+ FullyQualifiedErrorId : NativeCommandError
|
|
||||||
|
|
||||||
|
|
||||||
./src/app/dashboard/become-performer/page.tsx
|
|
||||||
Module not found: Can't resolve '@/components/RichTextEditor'
|
|
||||||
|
|
||||||
https://nextjs.org/docs/messages/module-not-found
|
|
||||||
|
|
||||||
./src/app/dashboard/become-performer/page.tsx
|
|
||||||
Module not found: Can't resolve '@/components/ui/collapsible'
|
|
||||||
|
|
||||||
https://nextjs.org/docs/messages/module-not-found
|
|
||||||
|
|
||||||
|
|
||||||
> Build failed because of webpack errors
|
|
||||||
Binary file not shown.
Generated
+35
-4
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
@@ -1675,6 +1677,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -6815,9 +6846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.71.1",
|
"version": "7.71.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
+4
-2
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
"@tiptap/react": "^3.19.0",
|
"@tiptap/react": "^3.19.0",
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
@@ -55,5 +57,5 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.1.4"
|
"packageManager": "npm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Users, ClipboardList, ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold">Обзор Панели Управления</h1>
|
||||||
|
|
||||||
|
{/* KPI Cards Placeholder */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Пользователей</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">+12,234</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+19% с прошлого месяца</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Активных Заказов</CardTitle>
|
||||||
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">+573</div>
|
||||||
|
<p className="text-xs text-muted-foreground">+5% с прошлого месяца</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Открытые Споры</CardTitle>
|
||||||
|
<ShieldAlert className="h-4 w-4 text-destructive" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">12</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Требуют внимания арбитра</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Disputes or Activity Placeholder */}
|
||||||
|
<h2 className="text-xl font-semibold mt-8 mb-4">Деятельность</h2>
|
||||||
|
<div className="rounded-md border bg-card p-8 flex justify-center text-muted-foreground">
|
||||||
|
<p>Данные загружаются...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex">
|
||||||
|
{/* Sidebar Placeholder */}
|
||||||
|
<aside className="w-64 bg-white dark:bg-zinc-900 border-r border-border flex-shrink-0 flex flex-col p-4">
|
||||||
|
<div className="font-bold text-xl mb-8">Admin Panel</div>
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
<div className="p-2 rounded bg-zinc-100 dark:bg-zinc-800 cursor-pointer text-sm font-medium">Dashboard</div>
|
||||||
|
<div className="p-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer text-sm text-muted-foreground transition-colors">Users</div>
|
||||||
|
<div className="p-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer text-sm text-muted-foreground transition-colors">Disputes</div>
|
||||||
|
<div className="p-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer text-sm text-muted-foreground transition-colors">Settings</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="flex-1 p-8 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-6
@@ -8,7 +8,7 @@ import { z } from 'zod'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { useSessionStore } from '@/entities/session/store'
|
import { useSessionStore } from '@/entities/session/store'
|
||||||
import { Loader2, Clock, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'
|
import { Loader2, Clock, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
@@ -171,11 +171,6 @@ export default function BecomePerformerPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScheduleOpen = () => {
|
|
||||||
form.setValue('workSchedule.isAlwaysReady', false)
|
|
||||||
setScheduleCollapsed(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Поиск адресов через Nominatim API (как в TagInput - только лучшее совпадение)
|
// Поиск адресов через Nominatim API (как в TagInput - только лучшее совпадение)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const searchAddress = async () => {
|
const searchAddress = async () => {
|
||||||
@@ -194,6 +189,7 @@ export default function BecomePerformerPage() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const addresses = data.map((item: any) => {
|
const addresses = data.map((item: any) => {
|
||||||
const city = item.address.city || item.address.town || item.address.village || ''
|
const city = item.address.city || item.address.town || item.address.village || ''
|
||||||
const street = item.address.road || ''
|
const street = item.address.road || ''
|
||||||
@@ -15,13 +15,13 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
User,
|
User,
|
||||||
|
Star,
|
||||||
MapPin,
|
MapPin,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CreditCard,
|
Gavel,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useSessionStore } from "@/entities/session/store"
|
import { useSessionStore } from "@/entities/session/store"
|
||||||
import { formatPrice } from "@/shared/lib/formatPrice"
|
import { formatPrice } from "@/shared/lib/formatPrice"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
import {
|
||||||
Order,
|
Order,
|
||||||
OrderStatus,
|
OrderStatus,
|
||||||
@@ -30,9 +30,18 @@ import {
|
|||||||
acceptOrder,
|
acceptOrder,
|
||||||
completeOrder,
|
completeOrder,
|
||||||
cancelOrder,
|
cancelOrder,
|
||||||
|
finishByPerformer,
|
||||||
|
confirmOrderCompletion,
|
||||||
} from "@/shared/api/orders"
|
} from "@/shared/api/orders"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ReviewModal } from "@/components/reputation/ReviewModal"
|
||||||
|
import { DisputeModal } from "@/components/orders/DisputeModal"
|
||||||
|
import { DisputeResponseModal } from "@/components/orders/DisputeResponseModal"
|
||||||
|
import { useProfileReviews, useProfileRating } from "@/shared/api/reputation"
|
||||||
|
import { ReviewCard } from "@/components/reputation/ReviewCard"
|
||||||
|
import { getRatingBadgeClasses, getRatingStarColor } from "@/shared/lib/ratingColors"
|
||||||
|
import { DisputeDetailsModal } from "@/components/orders/DisputeDetailsModal"
|
||||||
|
|
||||||
// ─── SLA Countdown ──────────────────────────────────────────────────────────
|
// ─── SLA Countdown ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -109,6 +118,16 @@ const STATUS_CONFIG: Record<
|
|||||||
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
|
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
|
||||||
icon: <AlertCircle className="h-3 w-3" />,
|
icon: <AlertCircle className="h-3 w-3" />,
|
||||||
},
|
},
|
||||||
|
VerificationPending: {
|
||||||
|
label: "На проверке",
|
||||||
|
className: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300",
|
||||||
|
icon: <Clock className="h-3 w-3" />,
|
||||||
|
},
|
||||||
|
Disputed: {
|
||||||
|
label: "В споре",
|
||||||
|
className: "bg-rose-100 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300",
|
||||||
|
icon: <AlertCircle className="h-3 w-3" />,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: OrderStatus }) {
|
function StatusBadge({ status }: { status: OrderStatus }) {
|
||||||
@@ -131,10 +150,21 @@ function StatusBadge({ status }: { status: OrderStatus }) {
|
|||||||
interface OrderCardProps {
|
interface OrderCardProps {
|
||||||
order: Order
|
order: Order
|
||||||
userId: string
|
userId: string
|
||||||
isPerformerRole: boolean
|
onOpenReview?: (orderId: string, offerId: string, authorId: string, targetId: string, targetName: string) => void
|
||||||
|
onOpenDispute?: (orderId: string, customerId: string) => void
|
||||||
|
onOpenDisputeResponse?: (orderId: string, performerId: string) => void
|
||||||
|
onOpenDisputeDetails?: (order: Order) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
const DISPUTE_REASONS_MAP: Record<string, string> = {
|
||||||
|
"WorkNotPerformed": "Работы не выполнены",
|
||||||
|
"NotFullVolume": "Выполнены не в полном объеме",
|
||||||
|
"PoorQuality": "Выполнены некачественно",
|
||||||
|
"DeadlineViolated": "Нарушен срок",
|
||||||
|
"PriceIncreased": "Увеличилась стоимость",
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderCard({ order, userId, onOpenReview, onOpenDispute, onOpenDisputeResponse, onOpenDisputeDetails }: OrderCardProps) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -142,40 +172,67 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
qc.invalidateQueries({ queryKey: ["orders"] })
|
qc.invalidateQueries({ queryKey: ["orders"] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptMutation = useMutation({
|
const mutateAccept = useMutation({
|
||||||
mutationFn: () => acceptOrder(order.id, userId),
|
mutationFn: () => acceptOrder(order.id, userId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Заказ принят!")
|
toast.success("Заказ принят!")
|
||||||
invalidate()
|
invalidate()
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onError: (err: any) =>
|
onError: (err: any) =>
|
||||||
toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
|
toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
const mutateComplete = useMutation({
|
||||||
mutationFn: () => completeOrder(order.id, userId),
|
mutationFn: () => completeOrder(order.id, userId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Заказ завершён!")
|
toast.success("Заказ завершён!")
|
||||||
invalidate()
|
invalidate()
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onError: (err: any) =>
|
onError: (err: any) =>
|
||||||
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
|
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const mutateCancel = useMutation({
|
||||||
mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
|
mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Заказ отменён")
|
toast.success("Заказ отменён")
|
||||||
invalidate()
|
invalidate()
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onError: (err: any) =>
|
onError: (err: any) =>
|
||||||
toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
|
toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mutateFinishByPerformer = useMutation({
|
||||||
|
mutationFn: () => finishByPerformer(order.id, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Работа отправлена на проверку!")
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onError: (err: any) =>
|
||||||
|
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении работы"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutateConfirmCompletion = useMutation({
|
||||||
|
mutationFn: () => confirmOrderCompletion(order.id, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Выполнение подтверждено. Заказ завершён!")
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onError: (err: any) =>
|
||||||
|
toast.error(err?.response?.data?.detail ?? "Ошибка при подтверждении"),
|
||||||
|
})
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
acceptMutation.isPending ||
|
mutateAccept.isPending ||
|
||||||
completeMutation.isPending ||
|
mutateComplete.isPending ||
|
||||||
cancelMutation.isPending
|
mutateCancel.isPending ||
|
||||||
|
mutateFinishByPerformer.isPending ||
|
||||||
|
mutateConfirmCompletion.isPending
|
||||||
|
|
||||||
const isCustomer = order.customerId === userId
|
const isCustomer = order.customerId === userId
|
||||||
|
|
||||||
@@ -262,6 +319,27 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Инфо о споре */}
|
||||||
|
{order.status === "Disputed" && order.dispute && (
|
||||||
|
<div
|
||||||
|
onClick={() => onOpenDisputeDetails?.(order)}
|
||||||
|
className="flex flex-col gap-3 p-4 rounded-2xl bg-rose-500/5 border border-rose-500/10 shadow-inner cursor-pointer hover:bg-rose-500/10 transition-colors group/dispute"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Gavel className="h-4 w-4 text-rose-600 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase font-black text-rose-600/60 tracking-widest">Идет спор</p>
|
||||||
|
<p className="text-[12px] font-bold text-rose-700">{DISPUTE_REASONS_MAP[order.dispute.reason] || order.dispute.reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 rounded-full text-[10px] font-bold uppercase tracking-tight text-rose-600 hover:bg-rose-500/20">
|
||||||
|
Детали
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Доп. инфо (отмена) */}
|
{/* Доп. инфо (отмена) */}
|
||||||
{order.cancellationReason && (
|
{order.cancellationReason && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-2xl bg-red-500/5 text-[12px] text-red-600 dark:text-red-400 border border-red-500/10 shadow-inner">
|
<div className="flex items-start gap-3 p-4 rounded-2xl bg-red-500/5 text-[12px] text-red-600 dark:text-red-400 border border-red-500/10 shadow-inner">
|
||||||
@@ -281,9 +359,9 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
className="rounded-2xl h-12 shadow-lg shadow-primary/20 bg-primary hover:bg-primary/90 font-bold text-sm"
|
className="rounded-2xl h-12 shadow-lg shadow-primary/20 bg-primary hover:bg-primary/90 font-bold text-sm"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => acceptMutation.mutate()}
|
onClick={() => mutateAccept.mutate()}
|
||||||
>
|
>
|
||||||
{acceptMutation.isPending ? (
|
{mutateAccept.isPending ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Принять заказ"
|
"Принять заказ"
|
||||||
@@ -293,9 +371,9 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-2xl h-12 border-red-500/20 text-red-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all font-bold text-sm"
|
className="rounded-2xl h-12 border-red-500/20 text-red-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all font-bold text-sm"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => cancelMutation.mutate("Отклонён мастером")}
|
onClick={() => mutateCancel.mutate("Отклонён мастером")}
|
||||||
>
|
>
|
||||||
{cancelMutation.isPending ? (
|
{mutateCancel.isPending ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Отклонить"
|
"Отклонить"
|
||||||
@@ -304,14 +382,14 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Завершить работу (мастер или клиент) */}
|
{/* Завершить работу (мастер) */}
|
||||||
{order.status === "InProgress" && (
|
{!isCustomer && order.status === "InProgress" && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full rounded-2xl h-12 shadow-lg shadow-blue-500/20 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold text-sm"
|
className="w-full rounded-2xl h-12 shadow-lg shadow-blue-500/20 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold text-sm"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => completeMutation.mutate()}
|
onClick={() => mutateFinishByPerformer.mutate()}
|
||||||
>
|
>
|
||||||
{completeMutation.isPending ? (
|
{mutateFinishByPerformer.isPending ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Завершить работу"
|
"Завершить работу"
|
||||||
@@ -319,21 +397,73 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Клиент: Подтвердить / Оспорить */}
|
||||||
|
{isCustomer && order.status === "VerificationPending" && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Button
|
||||||
|
className="rounded-2xl h-12 shadow-lg shadow-green-500/20 bg-green-600 hover:bg-green-700 text-white font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => mutateConfirmCompletion.mutate()}
|
||||||
|
>
|
||||||
|
{mutateConfirmCompletion.isPending ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
Подтвердить
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-2xl h-12 border-rose-500/20 text-rose-600 hover:bg-rose-500 hover:text-white transition-all font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => onOpenDispute?.(order.id, userId)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4 mr-2" />
|
||||||
|
Оспорить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Мастер: Ответить на спор */}
|
||||||
|
{!isCustomer && order.status === "Disputed" && (
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-2xl h-12 shadow-lg shadow-rose-500/20 bg-rose-600 hover:bg-rose-700 text-white font-bold text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => onOpenDisputeResponse?.(order.id, userId)}
|
||||||
|
>
|
||||||
|
<Gavel className="h-4 w-4 mr-2" />
|
||||||
|
Ответить на спор
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Клиент: Отменить (пока мастер не принял) */}
|
{/* Клиент: Отменить (пока мастер не принял) */}
|
||||||
{isCustomer && order.status === "PendingAcceptance" && (
|
{isCustomer && order.status === "PendingAcceptance" && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full rounded-2xl h-12 text-muted-foreground hover:text-red-500 hover:bg-red-500/5 transition-all font-bold text-sm"
|
className="w-full rounded-2xl h-12 text-muted-foreground hover:text-red-500 hover:bg-red-500/5 transition-all font-bold text-sm"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => cancelMutation.mutate("Отменён заказчиком")}
|
onClick={() => mutateCancel.mutate("Отменён заказчиком")}
|
||||||
>
|
>
|
||||||
{cancelMutation.isPending ? (
|
{mutateCancel.isPending ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
"Отменить заказ"
|
"Отменить заказ"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Клиент: Оценить работу (для завершённых заказов) */}
|
||||||
|
{isCustomer && order.status === "Completed" && order.performerId && (
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-2xl h-12 shadow-lg shadow-indigo-500/20 bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-sm"
|
||||||
|
onClick={() => onOpenReview?.(order.id, order.serviceId, userId, order.performerId!, order.performerName || 'Мастер')}
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4 mr-2" />
|
||||||
|
Оценить работу
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,10 +473,10 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|||||||
// ─── Главная страница ─────────────────────────────────────────────────────────
|
// ─── Главная страница ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
|
const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
|
||||||
const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"]
|
const PROGRESS_STATUSES: OrderStatus[] = ["InProgress", "VerificationPending", "Disputed"]
|
||||||
const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
|
const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
|
||||||
|
|
||||||
type OrderTab = "new" | "process" | "done"
|
type OrderTab = "new" | "process" | "done" | "reviews"
|
||||||
|
|
||||||
export default function OrdersDashboardPage() {
|
export default function OrdersDashboardPage() {
|
||||||
const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
|
const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
|
||||||
@@ -355,6 +485,86 @@ export default function OrdersDashboardPage() {
|
|||||||
const [customerTab, setCustomerTab] = React.useState<OrderTab>("new")
|
const [customerTab, setCustomerTab] = React.useState<OrderTab>("new")
|
||||||
const [performerTab, setPerformerTab] = React.useState<OrderTab>("new")
|
const [performerTab, setPerformerTab] = React.useState<OrderTab>("new")
|
||||||
|
|
||||||
|
// Состояние для модалки отзыва
|
||||||
|
const [reviewModal, setReviewModal] = React.useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
orderId: string;
|
||||||
|
offerId: string;
|
||||||
|
authorId: string;
|
||||||
|
targetId: string;
|
||||||
|
targetName: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
orderId: '',
|
||||||
|
offerId: '',
|
||||||
|
authorId: '',
|
||||||
|
targetId: '',
|
||||||
|
targetName: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const openReviewModal = (orderId: string, offerId: string, authorId: string, targetId: string, targetName: string) => {
|
||||||
|
setReviewModal({ isOpen: true, orderId, offerId, authorId, targetId, targetName })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeReviewModal = () => {
|
||||||
|
setReviewModal(prev => ({ ...prev, isOpen: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Состояние для модалки спора
|
||||||
|
const [disputeModal, setDisputeModal] = React.useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
orderId: string;
|
||||||
|
customerId: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
orderId: '',
|
||||||
|
customerId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDisputeModal = (orderId: string, customerId: string) => {
|
||||||
|
setDisputeModal({ isOpen: true, orderId, customerId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDisputeModal = () => {
|
||||||
|
setDisputeModal(prev => ({ ...prev, isOpen: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Состояние для модалки ответа на спор
|
||||||
|
const [disputeResponseModal, setDisputeResponseModal] = React.useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
orderId: string;
|
||||||
|
performerId: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
orderId: '',
|
||||||
|
performerId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDisputeResponseModal = (orderId: string, performerId: string) => {
|
||||||
|
setDisputeResponseModal({ isOpen: true, orderId, performerId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDisputeResponseModal = () => {
|
||||||
|
setDisputeResponseModal(prev => ({ ...prev, isOpen: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Состояние для модалки деталей спора (новая)
|
||||||
|
const [disputeDetailsModal, setDisputeDetailsModal] = React.useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
order: Order | null;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
order: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDisputeDetailsModal = (order: Order) => {
|
||||||
|
setDisputeDetailsModal({ isOpen: true, order })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDisputeDetailsModal = () => {
|
||||||
|
setDisputeDetailsModal(prev => ({ ...prev, isOpen: false, order: null }))
|
||||||
|
}
|
||||||
|
|
||||||
// Редирект если не авторизован
|
// Редирект если не авторизован
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isInitialized && !isAuthLoading && !isAuth) {
|
if (isInitialized && !isAuthLoading && !isAuth) {
|
||||||
@@ -424,11 +634,14 @@ export default function OrdersDashboardPage() {
|
|||||||
allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
|
allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const allOrders = Array.from(allOrdersMap.values())
|
const allOrders = Array.from(allOrdersMap.values())
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const isLoading =
|
const isLoading =
|
||||||
(customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
|
(customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
|
||||||
const isFetching = customerQuery.isFetching || performerQuery.isFetching
|
const isFetching = customerQuery.isFetching || performerQuery.isFetching
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const error = customerQuery.error || performerQuery.error
|
const error = customerQuery.error || performerQuery.error
|
||||||
|
|
||||||
const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
|
const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
|
||||||
@@ -450,6 +663,9 @@ export default function OrdersDashboardPage() {
|
|||||||
const customerCounts = getCounts(customerQuery.data)
|
const customerCounts = getCounts(customerQuery.data)
|
||||||
const performerCounts = getCounts(performerQuery.data)
|
const performerCounts = getCounts(performerQuery.data)
|
||||||
|
|
||||||
|
const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(user?.id)
|
||||||
|
const { data: ratingData } = useProfileRating(user?.id)
|
||||||
|
|
||||||
// Ждём инициализации сессии
|
// Ждём инициализации сессии
|
||||||
if (!isInitialized || isAuthLoading) {
|
if (!isInitialized || isAuthLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -493,7 +709,7 @@ export default function OrdersDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-start">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 items-start">
|
||||||
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
|
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
@@ -556,7 +772,7 @@ export default function OrdersDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
getFilteredOrders(customerQuery.data, customerTab).map(order => (
|
getFilteredOrders(customerQuery.data, customerTab).map(order => (
|
||||||
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={false} />
|
<OrderCard key={order.id} order={order} userId={user.id} onOpenReview={openReviewModal} onOpenDispute={openDisputeModal} onOpenDisputeResponse={openDisputeResponseModal} onOpenDisputeDetails={openDisputeDetailsModal} />
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -634,7 +850,7 @@ export default function OrdersDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
getFilteredOrders(performerQuery.data, performerTab).map(order => (
|
getFilteredOrders(performerQuery.data, performerTab).map(order => (
|
||||||
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={true} />
|
<OrderCard key={order.id} order={order} userId={user.id} onOpenReview={openReviewModal} onOpenDispute={openDisputeModal} onOpenDisputeResponse={openDisputeResponseModal} onOpenDisputeDetails={openDisputeDetailsModal} />
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -642,7 +858,83 @@ export default function OrdersDashboardPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 🌟 ВИДЖЕТ: ОТЗЫВЫ (Я МАСТЕР) */}
|
||||||
|
<section className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Star className="h-5 w-5 text-indigo-500" />
|
||||||
|
Мои отзывы
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Что говорят клиенты</p>
|
||||||
</div>
|
</div>
|
||||||
|
{ratingData && (
|
||||||
|
<div className={cn("flex items-center gap-2 px-3 py-1.5 rounded-2xl border", getRatingBadgeClasses(ratingData.averageRating))}>
|
||||||
|
<span className="text-sm font-black">{ratingData.averageRating.toFixed(1)}</span>
|
||||||
|
<Star className={cn("h-3.5 w-3.5", getRatingStarColor(ratingData.averageRating))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 min-h-[400px]">
|
||||||
|
{isReviewsLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-indigo-500" />
|
||||||
|
<span className="text-[11px] font-medium italic">Загрузка отзывов...</span>
|
||||||
|
</div>
|
||||||
|
) : !reviewsData || reviewsData.reviews.length === 0 ? (
|
||||||
|
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
||||||
|
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<Star className="h-6 w-6 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-medium">Отзывов пока нет</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
reviewsData.reviews.map(review => (
|
||||||
|
<ReviewCard key={review.id} review={review} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модалка отзыва */}
|
||||||
|
<ReviewModal
|
||||||
|
isOpen={reviewModal.isOpen}
|
||||||
|
onClose={closeReviewModal}
|
||||||
|
orderId={reviewModal.orderId}
|
||||||
|
offerId={reviewModal.offerId}
|
||||||
|
authorId={reviewModal.authorId}
|
||||||
|
targetId={reviewModal.targetId}
|
||||||
|
targetName={reviewModal.targetName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модалка спора */}
|
||||||
|
<DisputeModal
|
||||||
|
isOpen={disputeModal.isOpen}
|
||||||
|
onClose={closeDisputeModal}
|
||||||
|
orderId={disputeModal.orderId}
|
||||||
|
customerId={disputeModal.customerId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модалка ответа на спор */}
|
||||||
|
<DisputeResponseModal
|
||||||
|
isOpen={disputeResponseModal.isOpen}
|
||||||
|
onClose={closeDisputeResponseModal}
|
||||||
|
orderId={disputeResponseModal.orderId}
|
||||||
|
performerId={disputeResponseModal.performerId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Модалка деталей спора (новая) */}
|
||||||
|
{disputeDetailsModal.order && (
|
||||||
|
<DisputeDetailsModal
|
||||||
|
isOpen={disputeDetailsModal.isOpen}
|
||||||
|
onClose={closeDisputeDetailsModal}
|
||||||
|
order={disputeDetailsModal.order}
|
||||||
|
userId={user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+6
-3
@@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -24,10 +23,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
import { updateOffer, getOfferById, Category } from "@/shared/api/catalog";
|
import { updateOffer, getOfferById } from "@/shared/api/catalog";
|
||||||
import { useSessionStore } from "@/entities/session/store";
|
import { useSessionStore } from "@/entities/session/store";
|
||||||
import { ImageUploader } from "@/components/ui/image-uploader";
|
import { ImageUploader } from "@/components/ui/image-uploader";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
@@ -111,6 +110,8 @@ export default function EditOfferPage() {
|
|||||||
images: offerData.images || [],
|
images: offerData.images || [],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
toast.error("Не удалось загрузить данные услуги");
|
toast.error("Не удалось загрузить данные услуги");
|
||||||
router.push("/dashboard/offers");
|
router.push("/dashboard/offers");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,6 +146,8 @@ export default function EditOfferPage() {
|
|||||||
toast.success("Услуга успешно обновлена");
|
toast.success("Услуга успешно обновлена");
|
||||||
router.push("/dashboard/offers");
|
router.push("/dashboard/offers");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
toast.error("Не удалось обновить услугу. Проверьте данные и попробуйте снова.");
|
toast.error("Не удалось обновить услугу. Проверьте данные и попробуйте снова.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
+6
-2
@@ -24,7 +24,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||||
import { getCategories, createOffer, Category } from "@/shared/api/catalog";
|
import { getCategories, createOffer, Category } from "@/shared/api/catalog";
|
||||||
@@ -93,6 +93,8 @@ export default function CreateOfferPage() {
|
|||||||
const data = await getCategories();
|
const data = await getCategories();
|
||||||
setCategories(data || []);
|
setCategories(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
toast.error("Ошибка загрузки категорий");
|
toast.error("Ошибка загрузки категорий");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -126,6 +128,8 @@ export default function CreateOfferPage() {
|
|||||||
toast.success("Услуга успешно создана");
|
toast.success("Услуга успешно создана");
|
||||||
router.push("/dashboard/offers");
|
router.push("/dashboard/offers");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error);
|
||||||
toast.error("Не удалось создать услугу. Проверьте данные и попробуйте снова.");
|
toast.error("Не удалось создать услугу. Проверьте данные и попробуйте снова.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -286,7 +290,7 @@ export default function CreateOfferPage() {
|
|||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium leading-none mb-1">Дополнительные характеристики</h3>
|
<h3 className="text-sm font-medium leading-none mb-1">Дополнительные характеристики</h3>
|
||||||
<p className="text-sm text-muted-foreground">Добавьте важные детали ("Опыт", "Гарантия")</p>
|
<p className="text-sm text-muted-foreground">Добавьте важные детали ("Опыт", "Гарантия")</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle, Image as ImageIcon } from "lucide-react";
|
import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle } from "lucide-react";
|
||||||
import { useSessionStore } from "@/entities/session/store";
|
import { useSessionStore } from "@/entities/session/store";
|
||||||
import { formatPrice } from "@/shared/lib/formatPrice";
|
import { formatPrice } from "@/shared/lib/formatPrice";
|
||||||
|
|
||||||
@@ -32,8 +32,7 @@ export default function MyOffersPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await getMyOffers();
|
const data = await getMyOffers();
|
||||||
setOffers(data || []);
|
setOffers(data || []);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(error);
|
|
||||||
toast.error("Не удалось загрузить ваши услуги");
|
toast.error("Не удалось загрузить ваши услуги");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/entities/session/store"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Loader2, User, Phone, ShieldCheck, Hammer, Award, Clock, MapPin, Trash2, Edit, Key, LogOut } from "lucide-react"
|
import { Loader2, User, Phone, ShieldCheck, Hammer, Award, Clock, MapPin, Trash2, Edit, Key } from "lucide-react"
|
||||||
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
@@ -19,7 +19,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
|||||||
import { formatPhoneNumber } from "@/shared/lib/utils"
|
import { formatPhoneNumber } from "@/shared/lib/utils"
|
||||||
import { AvatarUploader } from "@/components/ui/avatar-uploader"
|
import { AvatarUploader } from "@/components/ui/avatar-uploader"
|
||||||
import { TagInput } from "@/components/ui/tag-input"
|
import { TagInput } from "@/components/ui/tag-input"
|
||||||
import { Rating, useRandomRating } from "@/components/ui/rating"
|
import { Rating } from "@/components/ui/rating"
|
||||||
|
import { useProfileRating } from "@/shared/api/reputation"
|
||||||
import { DescriptionViewer } from "@/components/ui/description-viewer"
|
import { DescriptionViewer } from "@/components/ui/description-viewer"
|
||||||
import { ScheduleViewer } from "@/components/ui/schedule-viewer"
|
import { ScheduleViewer } from "@/components/ui/schedule-viewer"
|
||||||
import { ChangePasswordModal } from "@/components/ui/change-password-modal"
|
import { ChangePasswordModal } from "@/components/ui/change-password-modal"
|
||||||
@@ -67,7 +68,7 @@ export default function ProfilePage() {
|
|||||||
const [isLocationModalOpen, setIsLocationModalOpen] = React.useState(false)
|
const [isLocationModalOpen, setIsLocationModalOpen] = React.useState(false)
|
||||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false)
|
const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false)
|
||||||
|
|
||||||
const rating = useRandomRating()
|
const { data: ratingData, isLoading: ratingLoading } = useProfileRating(user?.id)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isInitialized) return
|
if (!isInitialized) return
|
||||||
@@ -77,7 +78,7 @@ export default function ProfilePage() {
|
|||||||
} else if (isAuth && !user) {
|
} else if (isAuth && !user) {
|
||||||
getProfile()
|
getProfile()
|
||||||
}
|
}
|
||||||
}, [isAuth, isLoading, user, router, isInitialized])
|
}, [isAuth, isLoading, user, router, isInitialized, getProfile])
|
||||||
|
|
||||||
const profileForm = useForm<ProfileValues>({
|
const profileForm = useForm<ProfileValues>({
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
@@ -110,6 +111,8 @@ export default function ProfilePage() {
|
|||||||
setIsEditingProfile(false)
|
setIsEditingProfile(false)
|
||||||
toast.success("Профиль успешно обновлен")
|
toast.success("Профиль успешно обновлен")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error)
|
||||||
toast.error("Ошибка при обновлении профиля")
|
toast.error("Ошибка при обновлении профиля")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +123,8 @@ export default function ProfilePage() {
|
|||||||
setIsChangingPhone(false)
|
setIsChangingPhone(false)
|
||||||
toast.success("Номер телефона успешно изменен")
|
toast.success("Номер телефона успешно изменен")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error)
|
||||||
toast.error("Ошибка при смене номера телефона")
|
toast.error("Ошибка при смене номера телефона")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +192,7 @@ export default function ProfilePage() {
|
|||||||
(isMaster || isCompany) && user.location && { id: "location", label: "Локация", icon: MapPin },
|
(isMaster || isCompany) && user.location && { id: "location", label: "Локация", icon: MapPin },
|
||||||
(isMaster || isCompany) && { id: "schedule", label: "График работы", icon: Clock },
|
(isMaster || isCompany) && { id: "schedule", label: "График работы", icon: Clock },
|
||||||
{ id: "account", label: "Управление аккаунтом", icon: ShieldCheck },
|
{ id: "account", label: "Управление аккаунтом", icon: ShieldCheck },
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
].filter(Boolean) as { id: string, label: string, icon: any }[]
|
].filter(Boolean) as { id: string, label: string, icon: any }[]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -207,7 +213,13 @@ export default function ProfilePage() {
|
|||||||
<Badge variant="secondary" className="text-sm font-medium px-3 py-0.5 whitespace-nowrap bg-muted hover:bg-muted text-muted-foreground border-transparent">
|
<Badge variant="secondary" className="text-sm font-medium px-3 py-0.5 whitespace-nowrap bg-muted hover:bg-muted text-muted-foreground border-transparent">
|
||||||
{translateRole(getHighestRole(user.roles))}
|
{translateRole(getHighestRole(user.roles))}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Rating value={rating} size="sm" />
|
{ratingLoading ? (
|
||||||
|
<span className="text-sm text-muted-foreground">Загрузка...</span>
|
||||||
|
) : ratingData ? (
|
||||||
|
<Rating value={ratingData.averageRating} size="sm" showCount count={ratingData.totalReviews} />
|
||||||
|
) : (
|
||||||
|
<Rating value={0} size="sm" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Header } from "@/widgets/Header";
|
||||||
|
|
||||||
|
export default function MainLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,15 @@ import { useEffect, useState } from "react"
|
|||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { getOfferById, Offer } from "@/shared/api/catalog"
|
import { getOfferById, Offer } from "@/shared/api/catalog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, Clock, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag } from "lucide-react"
|
import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag, Loader2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
||||||
import { formatPrice } from "@/shared/lib/formatPrice"
|
import { formatPrice } from "@/shared/lib/formatPrice"
|
||||||
|
import { useProfileRating, useProfileReviews, useOfferRating } from "@/shared/api/reputation"
|
||||||
|
import { usePublicProfile } from "@/shared/api/identity"
|
||||||
|
import { Rating } from "@/components/ui/rating"
|
||||||
|
import { ReviewCard } from "@/components/reputation/ReviewCard"
|
||||||
|
|
||||||
|
|
||||||
export default function PublicOfferPage() {
|
export default function PublicOfferPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -30,7 +35,9 @@ export default function PublicOfferPage() {
|
|||||||
if (data.images && data.images.length > 0) {
|
if (data.images && data.images.length > 0) {
|
||||||
setActiveImageIndex(0)
|
setActiveImageIndex(0)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (err: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
toast.error("Не удалось загрузить услугу")
|
toast.error("Не удалось загрузить услугу")
|
||||||
console.error("Fetch offer error:", error)
|
console.error("Fetch offer error:", error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +48,11 @@ export default function PublicOfferPage() {
|
|||||||
fetchOffer()
|
fetchOffer()
|
||||||
}, [offerId])
|
}, [offerId])
|
||||||
|
|
||||||
|
const { data: ratingData } = useProfileRating(offer?.performerId)
|
||||||
|
const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(offer?.performerId)
|
||||||
|
const { data: offerRatingData } = useOfferRating(offerId)
|
||||||
|
const { data: performerPublicData } = usePublicProfile(offer?.performerId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!offer || !offer.images || offer.images.length <= 1) return;
|
if (!offer || !offer.images || offer.images.length <= 1) return;
|
||||||
@@ -206,13 +218,50 @@ export default function PublicOfferPage() {
|
|||||||
{Object.entries(offer.attributes || {}).map(([key, value]) => (
|
{Object.entries(offer.attributes || {}).map(([key, value]) => (
|
||||||
<div key={key} className="flex justify-between items-center text-sm md:text-base border-b border-muted/60 pb-3 last:border-0 last:pb-0 pt-1">
|
<div key={key} className="flex justify-between items-center text-sm md:text-base border-b border-muted/60 pb-3 last:border-0 last:pb-0 pt-1">
|
||||||
<span className="text-muted-foreground">{key}</span>
|
<span className="text-muted-foreground">{key}</span>
|
||||||
<span className="font-semibold text-right max-w-xs break-words">{value}</span>
|
<span className="font-semibold text-right max-w-xs break-words">{String(value)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Отзывы */}
|
||||||
|
<div className="bg-card border rounded-xl shadow-sm mt-8 overflow-hidden">
|
||||||
|
<div className="bg-muted/30 px-6 py-4 border-b flex items-center justify-between">
|
||||||
|
<h3 className="text-xl font-bold flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="w-5 h-5 text-indigo-500" />
|
||||||
|
Отзывы ({ratingData?.totalReviews || 0})
|
||||||
|
</div>
|
||||||
|
{offerRatingData && offerRatingData.totalReviews > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground mt-2">
|
||||||
|
<span className="text-foreground">Рейтинг услуги: {offerRatingData.averageRating.toFixed(1)}</span>
|
||||||
|
<Rating value={offerRatingData.averageRating} size="sm" showValue={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{isReviewsLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||||
|
<span className="text-sm font-medium italic">Загрузка отзывов...</span>
|
||||||
|
</div>
|
||||||
|
) : !reviewsData || reviewsData.reviews.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground bg-muted/20 rounded-xl border border-dashed">
|
||||||
|
<Star className="w-12 h-12 mb-3 opacity-20" />
|
||||||
|
<p className="italic">Об этом мастере пока нет отзывов.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{reviewsData.reviews.map(review => (
|
||||||
|
<ReviewCard key={review.id} review={review} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Правая колонка - Карточка заказа / Информация */}
|
{/* Правая колонка - Карточка заказа / Информация */}
|
||||||
@@ -221,39 +270,38 @@ export default function PublicOfferPage() {
|
|||||||
<div className="bg-card border rounded-xl p-6 shadow-sm">
|
<div className="bg-card border rounded-xl p-6 shadow-sm">
|
||||||
<h3 className="text-lg font-bold mb-4">Об исполнителе</h3>
|
<h3 className="text-lg font-bold mb-4">Об исполнителе</h3>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<div className="w-14 h-14 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center text-white shrink-0">
|
<div className="w-14 h-14 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center text-white shrink-0 shadow-lg">
|
||||||
<User className="w-6 h-6" />
|
<User className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="overflow-hidden">
|
||||||
<h4 className="font-bold text-lg">Имя Фамилия</h4>
|
<h4 className="font-bold text-lg truncate flex items-center gap-2">
|
||||||
<p className="text-sm font-medium text-muted-foreground bg-muted inline-block px-2 py-0.5 rounded mt-1">Мастер</p>
|
<span title="Личность подтверждена">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-green-500 shrink-0" />
|
||||||
|
</span>
|
||||||
|
{performerPublicData?.fullName || offer.performerName || 'Мастер'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] font-black text-muted-foreground uppercase bg-muted inline-block px-2 py-0.5 rounded mt-1 tracking-wider">
|
||||||
|
{performerPublicData?.primaryRole || offer.performerRole || 'Специалист'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 border-b pb-4 mb-4">
|
<div className="flex flex-col gap-4 border-b pb-6 mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-2xl font-black flex items-center gap-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
5.0 <Star className="w-5 h-5 fill-yellow-400 text-yellow-400" />
|
<span className="text-3xl font-black text-foreground">{ratingData?.averageRating.toFixed(1) || '0.0'}</span>
|
||||||
</span>
|
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">
|
||||||
<span className="text-xs text-muted-foreground underline underline-offset-2 cursor-pointer hover:text-foreground transition-colors">
|
/ 5.0
|
||||||
42 отзыва
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px bg-border my-1" />
|
<Rating value={ratingData?.averageRating || 0} size="md" showValue={false} showCount count={ratingData?.totalReviews} />
|
||||||
<div className="flex flex-col justify-center">
|
</div>
|
||||||
<span className="text-sm font-medium flex items-center gap-1.5">
|
|
||||||
<ShieldCheck className="w-4 h-4 text-green-500" />
|
|
||||||
Документы проверены
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium flex items-center gap-1.5 mt-1 text-muted-foreground">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
На сайте 2 года
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full" variant="outline">
|
<Button className="w-full rounded-xl font-bold bg-muted hover:bg-muted-foreground/10 text-foreground transition-all" variant="ghost">
|
||||||
Перейти в профиль
|
В профиль
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
import * as React from "react"
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react"
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react"
|
||||||
import { Search, User, Filter, MapPin, Briefcase } from "lucide-react"
|
import {
|
||||||
import { SearchResultItem, SearchParams, globalSearch } from "@/shared/api/search"
|
Search as SearchIcon,
|
||||||
|
MapPin,
|
||||||
|
Star,
|
||||||
|
Hammer,
|
||||||
|
User,
|
||||||
|
SearchCheck,
|
||||||
|
ListOrdered
|
||||||
|
} from "lucide-react"
|
||||||
|
import { globalSearch, SearchResultItem } from "@/shared/api/search"
|
||||||
|
import { getRatingBadgeClasses, getRatingStarColor } from "@/shared/lib/ratingColors"
|
||||||
import { formatPrice } from "@/shared/lib/formatPrice"
|
import { formatPrice } from "@/shared/lib/formatPrice"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// ВАЖНО: react-leaflet ломает SSR — импортируем с ssr: false
|
|
||||||
const LiveMap = dynamic(() => import("@/components/map/LiveMap"), {
|
const LiveMap = dynamic(() => import("@/components/map/LiveMap"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
@@ -31,18 +41,15 @@ export default function SearchPage() {
|
|||||||
const [centerTrigger, setCenterTrigger] = useState(0)
|
const [centerTrigger, setCenterTrigger] = useState(0)
|
||||||
const [sortBy, setSortBy] = useState<string>("default")
|
const [sortBy, setSortBy] = useState<string>("default")
|
||||||
|
|
||||||
// Параметры поиска
|
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [inTitle, setInTitle] = useState(true)
|
const [inTitle, setInTitle] = useState(true)
|
||||||
const [inDesc, setInDesc] = useState(true)
|
const [inDesc, setInDesc] = useState(true)
|
||||||
const [inComp, setInComp] = useState(true)
|
const [inComp, setInComp] = useState(true)
|
||||||
|
|
||||||
// Текущая позиция карты (и инициализация из localStorage)
|
|
||||||
const [mapBounds, setMapBounds] = useState<{ lat: number, lon: number, radius: number } | null>(null)
|
const [mapBounds, setMapBounds] = useState<{ lat: number, lon: number, radius: number } | null>(null)
|
||||||
const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173]) // Default: Moscow
|
const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173])
|
||||||
const [isLocationLoaded, setIsLocationLoaded] = useState(false)
|
const [, setIsLocationLoaded] = useState(false)
|
||||||
|
|
||||||
// Пытаемся восстановить локацию при монтировании
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const savedProfile = localStorage.getItem("user-profile")
|
const savedProfile = localStorage.getItem("user-profile")
|
||||||
@@ -88,7 +95,6 @@ export default function SearchPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Debounced search trigger
|
|
||||||
const triggerSearch = useCallback((newQuery: string, lat?: number, lon?: number, rad?: number, title?: boolean, desc?: boolean, comp?: boolean) => {
|
const triggerSearch = useCallback((newQuery: string, lat?: number, lon?: number, rad?: number, title?: boolean, desc?: boolean, comp?: boolean) => {
|
||||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||||
|
|
||||||
@@ -96,7 +102,7 @@ export default function SearchPage() {
|
|||||||
const currentLon = lon ?? mapBounds?.lon
|
const currentLon = lon ?? mapBounds?.lon
|
||||||
const currentRad = rad ?? mapBounds?.radius
|
const currentRad = rad ?? mapBounds?.radius
|
||||||
|
|
||||||
if (!currentLat || !currentLon) return // Ждём, пока карта даст координаты
|
if (!currentLat || !currentLon) return
|
||||||
|
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
performSearch(
|
performSearch(
|
||||||
@@ -123,18 +129,15 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const handlePerformerClick = useCallback((id: string) => {
|
const handlePerformerClick = useCallback((id: string) => {
|
||||||
setActivePerformerId(id)
|
setActivePerformerId(id)
|
||||||
|
const scrollId = `performer-card-${id}`
|
||||||
if (sidebarOpen) {
|
if (sidebarOpen) {
|
||||||
const el = document.getElementById(`performer-card-${id}`)
|
const el = document.getElementById(scrollId)
|
||||||
if (el) {
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setSidebarOpen(true)
|
setSidebarOpen(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById(`performer-card-${id}`)
|
const el = document.getElementById(scrollId)
|
||||||
if (el) {
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
}
|
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
}, [sidebarOpen])
|
}, [sidebarOpen])
|
||||||
@@ -145,11 +148,12 @@ export default function SearchPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
let items: { type: 'offer' | 'performer', performer: SearchResultItem, offer?: any, distance: number, price: number }[] = [];
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const items: { type: 'offer' | 'performer', performer: SearchResultItem, offer?: any, distance: number, price: number }[] = [];
|
||||||
performers.forEach(p => {
|
performers.forEach(p => {
|
||||||
if (p.matchedOffers && p.matchedOffers.length > 0) {
|
if (p.matchedOffers && p.matchedOffers.length > 0) {
|
||||||
p.matchedOffers.forEach(o => {
|
p.matchedOffers.forEach(o => {
|
||||||
items.push({ type: 'offer', performer: p, offer: o, distance: p.distance || 0, price: o.priceAmount ?? o.amount ?? 0 });
|
items.push({ type: 'offer', performer: p, offer: o, distance: p.distance || 0, price: o.priceAmount ?? 0 });
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({ type: 'performer', performer: p, distance: p.distance || 0, price: 0 });
|
items.push({ type: 'performer', performer: p, distance: p.distance || 0, price: 0 });
|
||||||
@@ -168,14 +172,12 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full" style={{ height: "calc(100vh - 64px)" }}>
|
<div className="flex w-full" style={{ height: "calc(100vh - 64px)" }}>
|
||||||
{/* === Боковая панель: Поиск + Список мастеров === */}
|
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<aside className="w-2/5 min-w-[380px] max-w-[600px] border-r bg-background flex flex-col shrink-0 overflow-hidden shadow-xl z-10 relative">
|
<aside className="w-2/5 min-w-[380px] max-w-[600px] border-r bg-background flex flex-col shrink-0 overflow-hidden shadow-xl z-10 relative">
|
||||||
{/* Заголовок и фильтры */}
|
|
||||||
<div className="p-4 border-b space-y-4">
|
<div className="p-4 border-b space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||||
<Search className="h-5 w-5 text-primary" />
|
<SearchIcon className="h-5 w-5 text-primary" />
|
||||||
Поиск услуг
|
Поиск услуг
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
@@ -186,9 +188,8 @@ export default function SearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Строка поиска */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Что вы ищете? Напр. 'бар', 'ремонт'"
|
placeholder="Что вы ищете? Напр. 'бар', 'ремонт'"
|
||||||
className="pl-9 bg-muted/50 focus-visible:ring-primary"
|
className="pl-9 bg-muted/50 focus-visible:ring-primary"
|
||||||
@@ -200,7 +201,6 @@ export default function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Чекбоксы зон поиска */}
|
|
||||||
<div className="flex flex-col gap-2 pt-2">
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Искать в:</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Искать в:</p>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
@@ -238,7 +238,6 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Список найденных мастеров */}
|
|
||||||
<div className="flex-1 overflow-y-auto bg-muted/10">
|
<div className="flex-1 overflow-y-auto bg-muted/10">
|
||||||
{query.trim() === "" ? (
|
{query.trim() === "" ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
|
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
|
||||||
@@ -247,7 +246,7 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium">Введите запрос для поиска</p>
|
<p className="text-sm font-medium">Введите запрос для поиска</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Укажите название услуги, например 'уборка' или 'няня'.
|
Укажите название услуги, например 'уборка' или 'няня'.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : isLoading && performers.length === 0 ? (
|
) : isLoading && performers.length === 0 ? (
|
||||||
@@ -258,11 +257,11 @@ export default function SearchPage() {
|
|||||||
) : performers.length === 0 ? (
|
) : performers.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
|
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
|
||||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||||
<Search className="h-8 w-8 text-muted-foreground" />
|
<SearchIcon className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium">Ничего не найдено</p>
|
<p className="text-sm font-medium">Ничего не найдено</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Попробуйте изменить запрос, расширить зону поиска или убрать фильтры.
|
Мы не нашли исполнителей непосредственно в "{query}", но подобрали лучших мастеров поблизости.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -287,7 +286,8 @@ export default function SearchPage() {
|
|||||||
{sortedItems.map((item, idx) => {
|
{sortedItems.map((item, idx) => {
|
||||||
const p = item.performer
|
const p = item.performer
|
||||||
const isOnline = p.status === "Готов к заказу"
|
const isOnline = p.status === "Готов к заказу"
|
||||||
const key = item.type === 'offer' ? `${item.offer.id}-${idx}` : `${p.performerId}-${idx}`
|
const cardId = p.performerId
|
||||||
|
const key = item.type === 'offer' ? `${item.offer.id}-${idx}` : `${cardId}-${idx}`
|
||||||
|
|
||||||
const formatDistance = (d?: number) => {
|
const formatDistance = (d?: number) => {
|
||||||
if (d === undefined || d === null) return 'В вашем районе';
|
if (d === undefined || d === null) return 'В вашем районе';
|
||||||
@@ -302,11 +302,13 @@ export default function SearchPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
id={`performer-card-${p.performerId}-${idx}`}
|
id={`performer-card-${cardId}`}
|
||||||
onClick={() => handleListPerformerClick(p.performerId)}
|
onClick={() => handleListPerformerClick(cardId)}
|
||||||
className={`px-4 py-4 cursor-pointer transition-colors group ${activePerformerId === p.performerId ? 'bg-primary/5 shadow-inner border-l-4 border-l-primary' : 'hover:bg-muted/50 border-l-4 border-l-transparent'}`}
|
className={cn(
|
||||||
|
"px-4 py-4 cursor-pointer transition-colors group",
|
||||||
|
activePerformerId === cardId ? "bg-primary/5 shadow-inner border-l-4 border-l-primary" : "hover:bg-muted/50 border-l-4 border-l-transparent"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* Информация об услуге или компетенции (главная) */}
|
|
||||||
{item.type === 'offer' ? (
|
{item.type === 'offer' ? (
|
||||||
<div className="mb-4 flex gap-4 items-stretch">
|
<div className="mb-4 flex gap-4 items-stretch">
|
||||||
{item.offer.imageUrl && (
|
{item.offer.imageUrl && (
|
||||||
@@ -321,7 +323,33 @@ export default function SearchPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<div className="flex justify-between items-start gap-2 mb-2">
|
<div className="flex justify-between items-start gap-2 mb-2">
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
<h4 className="font-bold text-lg leading-tight group-hover:text-primary transition-colors line-clamp-2">{item.offer.title}</h4>
|
<h4 className="font-bold text-lg leading-tight group-hover:text-primary transition-colors line-clamp-2">{item.offer.title}</h4>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-tight">Мастер:</span>
|
||||||
|
{(p.rating ?? 0) > 0 ? (
|
||||||
|
<div className={cn("flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-[10px] font-black", getRatingBadgeClasses(p.rating ?? 0))}>
|
||||||
|
<span>{(p.rating ?? 0).toFixed(1)}</span>
|
||||||
|
<Star className={cn("h-2.5 w-2.5", getRatingStarColor(p.rating ?? 0))} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground italic">Без рейтинга</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 sm:border-l sm:pl-3">
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-tight">Услуга:</span>
|
||||||
|
{(item.offer.rating ?? 0) > 0 ? (
|
||||||
|
<div className={cn("flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-[10px] font-black", getRatingBadgeClasses(item.offer.rating ?? 0))}>
|
||||||
|
<span>{(item.offer.rating ?? 0).toFixed(1)}</span>
|
||||||
|
<Star className={cn("h-2.5 w-2.5", getRatingStarColor(item.offer.rating ?? 0))} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground italic font-medium">Без рейтинга</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
<span className="text-sm font-bold text-primary whitespace-nowrap bg-primary/10 px-2 py-0.5 rounded-md shadow-sm">
|
<span className="text-sm font-bold text-primary whitespace-nowrap bg-primary/10 px-2 py-0.5 rounded-md shadow-sm">
|
||||||
{item.price > 0 ? formatPrice(item.price) : "Цена не указана"}
|
{item.price > 0 ? formatPrice(item.price) : "Цена не указана"}
|
||||||
@@ -342,7 +370,7 @@ export default function SearchPage() {
|
|||||||
href={`/offers/${item.offer.id}`}
|
href={`/offers/${item.offer.id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()} // Prevent clicking the card
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input shadow-sm bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
|
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input shadow-sm bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
|
||||||
>
|
>
|
||||||
Просмотреть услугу
|
Просмотреть услугу
|
||||||
@@ -353,7 +381,7 @@ export default function SearchPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||||||
Найдено по компетенции
|
Найдено {sortedItems.length} предложений' по запросу “{query}”
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{p.matchedCompetencies.map(c => (
|
{p.matchedCompetencies.map(c => (
|
||||||
@@ -365,7 +393,6 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Карточка исполнителя */}
|
|
||||||
<div className={performerClasses}>
|
<div className={performerClasses}>
|
||||||
<div className="relative h-8 w-8 shrink-0 rounded-full overflow-hidden bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white">
|
<div className="relative h-8 w-8 shrink-0 rounded-full overflow-hidden bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
@@ -380,12 +407,34 @@ export default function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="font-medium text-xs truncate">{p.name}</p>
|
<p className="font-medium text-xs truncate flex items-center gap-1">
|
||||||
<span className={`flex items-center gap-1 text-[10px] font-medium shrink-0 flex-nowrap ${isOnline ? "text-green-500" : "text-gray-400"}`}>
|
<span title="Личность подтверждена">
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${isOnline ? "bg-green-500 animate-pulse" : "bg-gray-400"}`} />
|
<SearchCheck className="w-3.5 h-3.5 text-green-500 shrink-0" />
|
||||||
|
</span>
|
||||||
|
{p.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-tight mr-0.5">Рейтинг:</span>
|
||||||
|
{(p.rating ?? 0) > 0 ? (
|
||||||
|
<div className={cn("flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-[10px] font-black", getRatingBadgeClasses(p.rating ?? 0))}>
|
||||||
|
<span>{(p.rating ?? 0).toFixed(1)}</span>
|
||||||
|
<Star className={cn("h-2.5 w-2.5", getRatingStarColor(p.rating ?? 0))} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground italic bg-muted/50 px-1.5 py-0.5 rounded">Без рейтинга</span>
|
||||||
|
)}
|
||||||
|
<span className={cn(
|
||||||
|
"flex items-center gap-1 text-[10px] font-medium shrink-0 flex-nowrap",
|
||||||
|
isOnline ? "text-green-500" : "text-gray-400"
|
||||||
|
)}>
|
||||||
|
<span className={cn(
|
||||||
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||||
|
isOnline ? "bg-green-500 animate-pulse" : "bg-gray-400"
|
||||||
|
)} />
|
||||||
{p.status}
|
{p.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
@@ -406,39 +455,62 @@ export default function SearchPage() {
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!sidebarOpen && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
className="absolute left-2 top-20 z-[1000] bg-background border shadow-lg rounded-lg px-4 py-3 hover:bg-muted transition-colors flex items-center gap-2 text-sm font-medium"
|
className={cn(
|
||||||
>
|
"absolute left-4 top-4 z-20 h-10 w-10 rounded-full bg-background border shadow-md flex items-center justify-center text-primary transition-all hover:scale-105",
|
||||||
<Filter className="h-4 w-4 text-primary" />
|
sidebarOpen ? "opacity-0 invisible pointer-events-none" : "opacity-100 visible"
|
||||||
Фильтры и список
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<SearchCheck className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* === Карта === */}
|
<main className="flex-1 relative">
|
||||||
<div className="flex-1 relative z-0">
|
|
||||||
{isLocationLoaded ? (
|
|
||||||
<LiveMap
|
<LiveMap
|
||||||
|
initialCenter={initialCenter}
|
||||||
performers={performers}
|
performers={performers}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
initialCenter={initialCenter}
|
|
||||||
onBoundsChange={handleBoundsChange}
|
onBoundsChange={handleBoundsChange}
|
||||||
onUserLocated={handleUserLocated}
|
onUserLocated={handleUserLocated}
|
||||||
activePerformerId={activePerformerId}
|
activePerformerId={activePerformerId}
|
||||||
centerTrigger={centerTrigger}
|
centerTrigger={centerTrigger}
|
||||||
onPerformerClick={handlePerformerClick}
|
onPerformerClick={handlePerformerClick}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center w-full h-full bg-muted/30">
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="bg-background/90 backdrop-blur border rounded-lg p-2 shadow-sm flex flex-col gap-1">
|
||||||
<div className="h-10 w-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
<p className="text-muted-foreground text-sm">Определение вашей позиции...</p>
|
<Hammer className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">Легенда</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-2 hover:bg-muted/50 rounded transition-colors cursor-default">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span className="text-xs font-medium">Готов к заказу</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-2 hover:bg-muted/50 rounded transition-colors cursor-default">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">На заказе</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 p-2 hover:bg-muted/50 rounded transition-colors cursor-default">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-slate-400" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Офлайн</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="bg-background/90 backdrop-blur border rounded-lg p-3 shadow-sm flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ListOrdered className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold">СПЕКТР:</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-6 bg-green-500 rounded-sm" title="Высокий рейтинг" />
|
||||||
|
<div className="h-2 w-6 bg-blue-500 rounded-sm" title="Средний рейтинг" />
|
||||||
|
<div className="h-2 w-6 bg-rose-500 rounded-sm" title="Низкий рейтинг" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,633 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import {
|
|
||||||
Loader2,
|
|
||||||
Clock,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
AlertCircle,
|
|
||||||
Package,
|
|
||||||
Timer,
|
|
||||||
RefreshCw,
|
|
||||||
ShoppingBag,
|
|
||||||
User,
|
|
||||||
MapPin,
|
|
||||||
ArrowRight,
|
|
||||||
CreditCard,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useSessionStore } from "@/entities/session/store"
|
|
||||||
import { formatPrice } from "@/shared/lib/formatPrice"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import {
|
|
||||||
Order,
|
|
||||||
OrderStatus,
|
|
||||||
getMyOrdersAsCustomer,
|
|
||||||
getMyOrdersAsPerformer,
|
|
||||||
acceptOrder,
|
|
||||||
completeOrder,
|
|
||||||
cancelOrder,
|
|
||||||
} from "@/shared/api/orders"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// ─── SLA Countdown ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SlaCountdown({ slaSecondsLeft }: { slaSecondsLeft: number }) {
|
|
||||||
const [seconds, setSeconds] = React.useState(slaSecondsLeft)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setSeconds(slaSecondsLeft)
|
|
||||||
if (slaSecondsLeft <= 0) return
|
|
||||||
const interval = setInterval(() => setSeconds((p) => Math.max(0, p - 1)), 1000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [slaSecondsLeft])
|
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60)
|
|
||||||
const secs = seconds % 60
|
|
||||||
const isUrgent = seconds < 300 // < 5 минут — мигает красным
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 text-sm font-mono font-semibold",
|
|
||||||
isUrgent
|
|
||||||
? "text-red-500 animate-pulse"
|
|
||||||
: "text-amber-600 dark:text-amber-400"
|
|
||||||
)}
|
|
||||||
title="Время до автоматической отмены заказа"
|
|
||||||
>
|
|
||||||
<Timer className="h-3.5 w-3.5" />
|
|
||||||
{String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Статус-бейдж ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<
|
|
||||||
OrderStatus,
|
|
||||||
{ label: string; className: string; icon: React.ReactNode }
|
|
||||||
> = {
|
|
||||||
Created: {
|
|
||||||
label: "Создан",
|
|
||||||
className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
||||||
icon: <Package className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
Published: {
|
|
||||||
label: "Опубликован",
|
|
||||||
className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
|
||||||
icon: <Package className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
PendingAcceptance: {
|
|
||||||
label: "Ожидает мастера",
|
|
||||||
className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
|
||||||
icon: <Clock className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
InProgress: {
|
|
||||||
label: "В работе",
|
|
||||||
className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
|
||||||
icon: <RefreshCw className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
Completed: {
|
|
||||||
label: "Завершён",
|
|
||||||
className:
|
|
||||||
"bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
|
|
||||||
icon: <CheckCircle2 className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
Cancelled: {
|
|
||||||
label: "Отменён",
|
|
||||||
className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400",
|
|
||||||
icon: <XCircle className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
Expired: {
|
|
||||||
label: "Время истекло",
|
|
||||||
className:
|
|
||||||
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
|
|
||||||
icon: <AlertCircle className="h-3 w-3" />,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: OrderStatus }) {
|
|
||||||
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
|
||||||
cfg.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{cfg.icon}
|
|
||||||
{cfg.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Карточка заказа ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface OrderCardProps {
|
|
||||||
order: Order
|
|
||||||
userId: string
|
|
||||||
isPerformerRole: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
const invalidate = () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["orders"] })
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptMutation = useMutation({
|
|
||||||
mutationFn: () => acceptOrder(order.id, userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Заказ принят!")
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: (err: any) =>
|
|
||||||
toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
|
||||||
mutationFn: () => completeOrder(order.id, userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Заказ завершён!")
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: (err: any) =>
|
|
||||||
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
|
||||||
mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Заказ отменён")
|
|
||||||
invalidate()
|
|
||||||
},
|
|
||||||
onError: (err: any) =>
|
|
||||||
toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
acceptMutation.isPending ||
|
|
||||||
completeMutation.isPending ||
|
|
||||||
cancelMutation.isPending
|
|
||||||
|
|
||||||
const isCustomer = order.customerId === userId
|
|
||||||
// Мастер считается исполнителем, если он SelectedPerformerId (для Direct)
|
|
||||||
// или если он уже принял заказ (статус перешел в InProgress)
|
|
||||||
const isPerformer = order.performerId === userId
|
|
||||||
|
|
||||||
const formatDate = (date: string) =>
|
|
||||||
new Date(date).toLocaleString("ru-RU", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group rounded-2xl border bg-card p-0 shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden">
|
|
||||||
{/* Верхняя полоска с ролью */}
|
|
||||||
<div className={cn(
|
|
||||||
"px-5 py-2 text-[10px] font-bold uppercase tracking-wider flex items-center justify-between",
|
|
||||||
isCustomer
|
|
||||||
? "bg-primary/5 text-primary border-b border-primary/10"
|
|
||||||
: "bg-amber-500/5 text-amber-600 border-b border-amber-500/10"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isCustomer ? "🛒 Мой заказ (я клиент)" : "🛠 Заказ услуги (я мастер)"}
|
|
||||||
</div>
|
|
||||||
<span className="opacity-60">#{order.id.slice(0, 8)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 space-y-5">
|
|
||||||
{/* Шапка: Статус и Таймер */}
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<StatusBadge status={order.status} />
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{formatDate(order.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && (
|
|
||||||
<div className="bg-amber-50 dark:bg-amber-950/30 px-3 py-1.5 rounded-xl border border-amber-100 dark:border-amber-900">
|
|
||||||
<SlaCountdown slaSecondsLeft={order.slaSecondsLeft!} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Блок услуги (карточка) */}
|
|
||||||
<div className="rounded-xl border bg-muted/30 p-4 space-y-3">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h4 className="font-bold text-sm leading-tight text-foreground/90">
|
|
||||||
{order.serviceTitle}
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<User className="h-3 w-3" />
|
|
||||||
<span>
|
|
||||||
{isCustomer
|
|
||||||
? `Исполнитель: ${order.performerName || "Ожидание..."}`
|
|
||||||
: `Заказчик: ${order.customerName}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<p className="font-bold text-sm text-primary">
|
|
||||||
{formatPrice(order.priceAmount, order.priceType)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{order.address && (
|
|
||||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground pt-2 border-t border-border/50">
|
|
||||||
<MapPin className="h-3 w-3 shrink-0" />
|
|
||||||
<span className="line-clamp-1">{order.address}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Доп. инфо (отмена) */}
|
|
||||||
{order.cancellationReason && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-950/20 text-[11px] text-red-600 dark:text-red-400 border border-red-100 dark:border-red-900/30">
|
|
||||||
<XCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
|
||||||
<p>Причина отмены: {order.cancellationReason}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Кнопки действий */}
|
|
||||||
{/* Для мастера: Принять/Отклонить */}
|
|
||||||
{!isCustomer && order.status === "PendingAcceptance" && (
|
|
||||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
className="rounded-xl h-10 shadow-sm"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => acceptMutation.mutate()}
|
|
||||||
>
|
|
||||||
{acceptMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Принять заказ"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-xl h-10 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => cancelMutation.mutate("Отклонён мастером")}
|
|
||||||
>
|
|
||||||
{cancelMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Отклонить"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Завершить работу (мастер или клиент) */}
|
|
||||||
{order.status === "InProgress" && (
|
|
||||||
<Button
|
|
||||||
className="w-full rounded-xl h-10 shadow-sm bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => completeMutation.mutate()}
|
|
||||||
>
|
|
||||||
{completeMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Завершить работу"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Клиент: Отменить (пока мастер не принял) */}
|
|
||||||
{isCustomer && order.status === "PendingAcceptance" && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full rounded-xl h-10 text-muted-foreground hover:text-red-500 hover:bg-red-50 transition-colors"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => cancelMutation.mutate("Отменён заказчиком")}
|
|
||||||
>
|
|
||||||
{cancelMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Отменить заказ"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Главная страница ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
|
|
||||||
const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"]
|
|
||||||
const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
|
|
||||||
|
|
||||||
type OrderTab = "new" | "process" | "done"
|
|
||||||
|
|
||||||
export default function OrdersDashboardPage() {
|
|
||||||
const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const [customerTab, setCustomerTab] = React.useState<OrderTab>("new")
|
|
||||||
const [performerTab, setPerformerTab] = React.useState<OrderTab>("new")
|
|
||||||
|
|
||||||
// Редирект если не авторизован
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isInitialized && !isAuthLoading && !isAuth) {
|
|
||||||
router.push("/auth/login")
|
|
||||||
}
|
|
||||||
}, [isAuth, isAuthLoading, isInitialized, router])
|
|
||||||
|
|
||||||
const isMaster = !!user?.roles?.some((r) => ["Master", "Company", "Candidate", "Admin"].includes(r))
|
|
||||||
|
|
||||||
// ─── React Query с polling каждые 10 секунд ─────────────────────────────
|
|
||||||
|
|
||||||
const customerQuery = useQuery({
|
|
||||||
queryKey: ["orders", "customer", user?.id],
|
|
||||||
queryFn: () => getMyOrdersAsCustomer(user!.id),
|
|
||||||
enabled: !!user?.id,
|
|
||||||
refetchInterval: 10_000, // polling каждые 10 секунд
|
|
||||||
})
|
|
||||||
|
|
||||||
const performerQuery = useQuery({
|
|
||||||
queryKey: ["orders", "performer", user?.id],
|
|
||||||
queryFn: () => getMyOrdersAsPerformer(user!.id),
|
|
||||||
enabled: !!user?.id && isMaster,
|
|
||||||
refetchInterval: 10_000, // polling каждые 10 секунд
|
|
||||||
})
|
|
||||||
|
|
||||||
// Показываем уведомление при поступлении новых заказов
|
|
||||||
const notifiedOrdersRef = React.useRef<Set<string>>(new Set())
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (performerQuery.data && performerQuery.data.length > 0) {
|
|
||||||
const pendingOrders = performerQuery.data.filter(
|
|
||||||
(o) => o.status === "PendingAcceptance"
|
|
||||||
)
|
|
||||||
|
|
||||||
let hasNew = false
|
|
||||||
pendingOrders.forEach((o) => {
|
|
||||||
if (!notifiedOrdersRef.current.has(o.id)) {
|
|
||||||
notifiedOrdersRef.current.add(o.id)
|
|
||||||
hasNew = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (hasNew) {
|
|
||||||
toast("У вас новый заказ!", {
|
|
||||||
description: "Загляните во вкладку «Активные», чтобы принять его.",
|
|
||||||
icon: <ShoppingBag className="h-4 w-4 text-primary" />,
|
|
||||||
duration: 10000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [performerQuery.data])
|
|
||||||
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const refetch = () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["orders"] })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Объединяем заказы без дублей
|
|
||||||
const allOrdersMap = new Map<
|
|
||||||
string,
|
|
||||||
{ order: Order; isPerformerRole: boolean }
|
|
||||||
>()
|
|
||||||
; (customerQuery.data ?? []).forEach((o) =>
|
|
||||||
allOrdersMap.set(o.id, { order: o, isPerformerRole: false })
|
|
||||||
)
|
|
||||||
; (performerQuery.data ?? []).forEach((o) => {
|
|
||||||
if (!allOrdersMap.has(o.id)) {
|
|
||||||
allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const allOrders = Array.from(allOrdersMap.values())
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
(customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
|
|
||||||
const isFetching = customerQuery.isFetching || performerQuery.isFetching
|
|
||||||
const error = customerQuery.error || performerQuery.error
|
|
||||||
|
|
||||||
const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
|
|
||||||
if (!data) return []
|
|
||||||
if (tab === "new") return data.filter(o => NEW_STATUSES.includes(o.status))
|
|
||||||
if (tab === "process") return data.filter(o => PROGRESS_STATUSES.includes(o.status))
|
|
||||||
return data.filter(o => DONE_STATUSES.includes(o.status))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCounts = (data: Order[] | undefined) => {
|
|
||||||
if (!data) return { new: 0, process: 0, done: 0 }
|
|
||||||
return {
|
|
||||||
new: data.filter(o => NEW_STATUSES.includes(o.status)).length,
|
|
||||||
process: data.filter(o => PROGRESS_STATUSES.includes(o.status)).length,
|
|
||||||
done: data.filter(o => DONE_STATUSES.includes(o.status)).length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const customerCounts = getCounts(customerQuery.data)
|
|
||||||
const performerCounts = getCounts(performerQuery.data)
|
|
||||||
|
|
||||||
// Ждём инициализации сессии
|
|
||||||
if (!isInitialized || isAuthLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-[50vh] items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container max-w-7xl py-8 space-y-8">
|
|
||||||
{/* Заголовок */}
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-black tracking-tight flex items-center gap-3">
|
|
||||||
<User className="h-8 w-8 text-primary" />
|
|
||||||
Личный кабинет
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
Управление вашими заказами и услугами
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isFetching && (
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground flex items-center gap-1.5 bg-muted px-2.5 py-1 rounded-full animate-pulse">
|
|
||||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
|
||||||
Sync
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={refetch}
|
|
||||||
disabled={isFetching}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-start">
|
|
||||||
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
|
|
||||||
<section className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between px-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
||||||
<ShoppingBag className="h-5 w-5 text-blue-500" />
|
|
||||||
Заказы
|
|
||||||
</h2>
|
|
||||||
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Мои заказы</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-muted/40 rounded-[2rem] p-1.5 border border-border/40 backdrop-blur-sm">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{[
|
|
||||||
{ id: "new" as OrderTab, label: "Новые", count: customerCounts.new },
|
|
||||||
{ id: "process" as OrderTab, label: "В работе", count: customerCounts.process },
|
|
||||||
{ id: "done" as OrderTab, label: "Завершенные", count: customerCounts.done },
|
|
||||||
].map(t => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => setCustomerTab(t.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2.5 px-1 text-[11px] font-bold rounded-[1.5rem] transition-all duration-300 flex items-center justify-center gap-1.5",
|
|
||||||
customerTab === t.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
{t.count > 0 && (
|
|
||||||
<span className={cn(
|
|
||||||
"px-1.5 py-0.5 rounded-full text-[9px] min-w-[18px]",
|
|
||||||
customerTab === t.id ? "bg-primary text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{t.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 min-h-[400px]">
|
|
||||||
{customerQuery.isLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
||||||
<span className="text-[11px] font-medium italic">Загрузка ваших заказов...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
getFilteredOrders(customerQuery.data, customerTab).length === 0 ? (
|
|
||||||
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
|
||||||
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
|
||||||
<Package className="h-6 w-6 text-muted-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground font-medium">Здесь пока ничего нет</p>
|
|
||||||
{customerTab === "new" && (
|
|
||||||
<Button variant="outline" size="sm" className="rounded-full text-[10px] h-8" asChild>
|
|
||||||
<a href="/search">Найти услуги</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
getFilteredOrders(customerQuery.data, customerTab).map(order => (
|
|
||||||
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={false} />
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */}
|
|
||||||
<section className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between px-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
||||||
<RefreshCw className="h-5 w-5 text-amber-500" />
|
|
||||||
Услуги
|
|
||||||
</h2>
|
|
||||||
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Заказы клиентов</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMaster ? (
|
|
||||||
<div className="bg-card border border-amber-500/10 rounded-[2rem] p-10 text-center space-y-5 shadow-sm relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-5">
|
|
||||||
<User className="h-20 w-20" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-[240px] mx-auto">
|
|
||||||
Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую.
|
|
||||||
</p>
|
|
||||||
<Button variant="default" className="rounded-full px-8 shadow-lg shadow-primary/20" onClick={() => router.push("/dashboard/become-performer")}>
|
|
||||||
Стать мастером
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="bg-muted/40 rounded-[2rem] p-1.5 border border-border/40 backdrop-blur-sm">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{[
|
|
||||||
{ id: "new" as OrderTab, label: "Новые", count: performerCounts.new },
|
|
||||||
{ id: "process" as OrderTab, label: "В работе", count: performerCounts.process },
|
|
||||||
{ id: "done" as OrderTab, label: "Завершенные", count: performerCounts.done },
|
|
||||||
].map(t => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => setPerformerTab(t.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 py-2.5 px-1 text-[11px] font-bold rounded-[1.5rem] transition-all duration-300 flex items-center justify-center gap-1.5",
|
|
||||||
performerTab === t.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
{t.count > 0 && (
|
|
||||||
<span className={cn(
|
|
||||||
"px-1.5 py-0.5 rounded-full text-[9px] min-w-[18px]",
|
|
||||||
performerTab === t.id ? "bg-primary text-primary-foreground" : "bg-muted-foreground/20 text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{t.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 min-h-[400px]">
|
|
||||||
{performerQuery.isLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-3">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-amber-500" />
|
|
||||||
<span className="text-[11px] font-medium italic">Синхронизация заказов...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
getFilteredOrders(performerQuery.data, performerTab).length === 0 ? (
|
|
||||||
<div className="border border-dashed border-muted-foreground/20 rounded-[2rem] py-24 text-center space-y-4 bg-muted/5">
|
|
||||||
<div className="h-12 w-12 mx-auto rounded-full bg-muted flex items-center justify-center">
|
|
||||||
<Timer className="h-6 w-6 text-muted-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground font-medium">Новых заявок пока нет</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
getFilteredOrders(performerQuery.data, performerTab).map(order => (
|
|
||||||
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={true} />
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Логируем ошибку на сервис вроде Sentry
|
||||||
|
console.error("Global Error Caught:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<body>
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-foreground text-center p-6">
|
||||||
|
<h2 className="text-3xl font-bold mb-4">Упс! Что-то пошло не так</h2>
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-md">
|
||||||
|
Произошла непредвиденная ошибка. Мы уже работаем над ее устранением.
|
||||||
|
{process.env.NODE_ENV === "development" && (
|
||||||
|
<span className="block mt-4 text-xs bg-muted p-2 rounded text-left overflow-auto">
|
||||||
|
{error.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button onClick={() => reset()} variant="default">
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Вернуться на главную
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-4
@@ -31,10 +31,7 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<div className="relative flex min-h-screen flex-col">
|
{children}
|
||||||
<Header />
|
|
||||||
<main className="flex-1">{children}</main>
|
|
||||||
</div>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col items-center justify-center space-y-4 px-4 text-center pb-24">
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Search className="h-10 w-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight lg:text-5xl">404</h1>
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-foreground/80 sm:text-2xl">Страница не найдена</h2>
|
||||||
|
<p className="max-w-md text-muted-foreground">
|
||||||
|
Извините, но запрошенной вами страницы не существует, она была удалена или перенесена.
|
||||||
|
</p>
|
||||||
|
<div className="pt-6">
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/">Вернуться на главную</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import Link from "next/link"
|
|||||||
import { useSessionStore } from "@/entities/session/store"
|
import { useSessionStore } from "@/entities/session/store"
|
||||||
|
|
||||||
export function PerformerButton() {
|
export function PerformerButton() {
|
||||||
const { isAuth, user } = useSessionStore()
|
const { user } = useSessionStore()
|
||||||
|
|
||||||
// Проверяем, является ли пользователь исполнителем или компанией
|
// Проверяем, является ли пользователь исполнителем или компанией
|
||||||
const isMasterOrCompany = user?.roles?.includes("Master") || user?.roles?.includes("Company")
|
const isMasterOrCompany = user?.roles?.includes("Master") || user?.roles?.includes("Company")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { useSessionStore } from "@/entities/session/store"
|
import { useSessionStore } from "@/entities/session/store"
|
||||||
import { useRouter, usePathname } from "next/navigation"
|
import { useRouter, usePathname } from "next/navigation"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { RatingStats } from "@/components/reputation/RatingStats"
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { user, isAuth, getProfile } = useSessionStore()
|
const { user, isAuth, getProfile } = useSessionStore()
|
||||||
@@ -77,6 +78,7 @@ export function Header() {
|
|||||||
Подобрать заказ
|
Подобрать заказ
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<RatingStats userId={user!.id} size={16} className="hidden sm:flex" />
|
||||||
<Button variant="ghost" onClick={() => router.push('/dashboard/profile')}>
|
<Button variant="ghost" onClick={() => router.push('/dashboard/profile')}>
|
||||||
{user?.firstName || 'Профиль'}
|
{user?.firstName || 'Профиль'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react"
|
import { useEffect, useState, useCallback, useRef } from "react"
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet"
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet"
|
||||||
import L from "leaflet"
|
import L from "leaflet"
|
||||||
|
import { ShieldCheck } from "lucide-react"
|
||||||
import { SearchResultItem } from "@/shared/api/search"
|
import { SearchResultItem } from "@/shared/api/search"
|
||||||
import { Offer } from "@/shared/api/catalog"
|
import { Offer } from "@/shared/api/catalog"
|
||||||
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
||||||
|
import { RatingStats } from "@/components/reputation/RatingStats"
|
||||||
|
|
||||||
// Фикс иконок Leaflet для webpack/Next.js
|
// Фикс иконок Leaflet для webpack/Next.js
|
||||||
const defaultIcon = L.icon({
|
const defaultIcon = L.icon({
|
||||||
@@ -189,8 +191,8 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
|||||||
title: o.title,
|
title: o.title,
|
||||||
description: o.description ?? "",
|
description: o.description ?? "",
|
||||||
price: {
|
price: {
|
||||||
amount: o.priceAmount ?? o.amount ?? 0,
|
amount: o.priceAmount ?? 0,
|
||||||
currency: o.priceCurrency ?? o.currency ?? "RUB",
|
currency: o.priceCurrency ?? "RUB",
|
||||||
type: 0,
|
type: 0,
|
||||||
},
|
},
|
||||||
attributes: null,
|
attributes: null,
|
||||||
@@ -259,10 +261,16 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
|||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-sm min-w-[180px] space-y-2">
|
<div className="text-sm min-w-[180px] space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{p.name}</p>
|
<p className="font-semibold flex items-center gap-1.5">
|
||||||
|
<span title="Личность подтверждена">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-green-500 shrink-0" />
|
||||||
|
</span>
|
||||||
|
{p.name}
|
||||||
|
</p>
|
||||||
<p className={`text-xs font-medium mt-0.5 ${isOnline ? "text-green-600" : "text-gray-500"}`}>
|
<p className={`text-xs font-medium mt-0.5 ${isOnline ? "text-green-600" : "text-gray-500"}`}>
|
||||||
● {p.status}
|
● {p.status}
|
||||||
</p>
|
</p>
|
||||||
|
<RatingStats userId={p.performerId} size={14} className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{p.matchedOffers.length > 0 && (
|
{p.matchedOffers.length > 0 && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2, ShoppingBag, MapPin, Clock, ChevronDown } from "lucide-react"
|
import { Loader2, ShoppingBag, MapPin, Clock } from "lucide-react"
|
||||||
import { createOrder, CreateOrderPayload } from "@/shared/api/orders"
|
import { createOrder, CreateOrderPayload } from "@/shared/api/orders"
|
||||||
import { Offer } from "@/shared/api/catalog"
|
import { Offer } from "@/shared/api/catalog"
|
||||||
import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice"
|
import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice"
|
||||||
@@ -26,7 +26,7 @@ interface CreateOrderModalProps {
|
|||||||
/** Имя мастера (для отображения в заказе) */
|
/** Имя мастера (для отображения в заказе) */
|
||||||
performerName?: string
|
performerName?: string
|
||||||
/** Список услуг этого мастера (для выбора) */
|
/** Список услуг этого мастера (для выбора) */
|
||||||
offers: (Offer | any)[]
|
offers: Offer[]
|
||||||
/** Предустановка конкретной услуги */
|
/** Предустановка конкретной услуги */
|
||||||
preselectedOfferId?: string
|
preselectedOfferId?: string
|
||||||
userLocation?: { lat: number; lon: number; address?: string }
|
userLocation?: { lat: number; lon: number; address?: string }
|
||||||
@@ -63,7 +63,7 @@ export function CreateOrderModal({
|
|||||||
} else if (offers.length > 0 && !selectedOfferId) {
|
} else if (offers.length > 0 && !selectedOfferId) {
|
||||||
setSelectedOfferId(offers[0].id)
|
setSelectedOfferId(offers[0].id)
|
||||||
}
|
}
|
||||||
}, [preselectedOfferId, offers])
|
}, [preselectedOfferId, offers, selectedOfferId])
|
||||||
|
|
||||||
const selectedOffer = offers.find((o) => o.id === selectedOfferId)
|
const selectedOffer = offers.find((o) => o.id === selectedOfferId)
|
||||||
|
|
||||||
@@ -95,12 +95,14 @@ export function CreateOrderModal({
|
|||||||
performerName: performerName,
|
performerName: performerName,
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderId = await createOrder(payload)
|
await createOrder(payload)
|
||||||
toast.success("Заказ успешно оформлен! Мастер получит уведомление.")
|
toast.success("Заказ успешно оформлен! Мастер получит уведомление.")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
const message =
|
const message =
|
||||||
err?.response?.data?.detail ?? err?.response?.data ?? "Ошибка при оформлении заказа"
|
error?.response?.data?.detail ?? error?.response?.data ?? "Ошибка при оформлении заказа"
|
||||||
toast.error(String(message))
|
toast.error(String(message))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ImageUploader } from "@/components/ui/image-uploader";
|
||||||
|
import {
|
||||||
|
ShieldAlert,
|
||||||
|
Gavel,
|
||||||
|
User,
|
||||||
|
Clock,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Order,
|
||||||
|
respondToDispute,
|
||||||
|
rebutDispute,
|
||||||
|
acceptDisputeTerms
|
||||||
|
} from '@/shared/api/orders';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DisputeDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
order: Order;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISPUTE_REASONS_MAP: Record<string, string> = {
|
||||||
|
"WorkNotPerformed": "Работы не выполнены",
|
||||||
|
"NotFullVolume": "Выполнены не в полном объеме",
|
||||||
|
"PoorQuality": "Выполнены некачественно",
|
||||||
|
"DeadlineViolated": "Нарушен срок",
|
||||||
|
"PriceIncreased": "Увеличилась стоимость",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisputeDetailsModal = ({ isOpen, onClose, order, userId }: DisputeDetailsModalProps) => {
|
||||||
|
const [actionComment, setActionComment] = useState("");
|
||||||
|
const [actionCounterSolution, setActionCounterSolution] = useState("");
|
||||||
|
const [actionEvidence, setActionEvidence] = useState<string[]>([]);
|
||||||
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const isCustomer = order.customerId === userId;
|
||||||
|
const isPerformer = order.performerId === userId;
|
||||||
|
const dispute = order.dispute;
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutateRespond = useMutation({
|
||||||
|
mutationFn: () => respondToDispute(order.id, {
|
||||||
|
performerId: userId,
|
||||||
|
position: actionComment,
|
||||||
|
evidence: actionEvidence,
|
||||||
|
counterSolution: actionCounterSolution || undefined
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Ваш ответ отправлен заказчику.");
|
||||||
|
invalidate();
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const error = err as any;
|
||||||
|
toast.error(error?.response?.data?.detail ?? "Ошибка при отправке ответа")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutateRebut = useMutation({
|
||||||
|
mutationFn: () => rebutDispute(order.id, {
|
||||||
|
customerId: userId,
|
||||||
|
comment: actionComment,
|
||||||
|
evidence: actionEvidence,
|
||||||
|
counterSolution: actionCounterSolution || undefined
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Ваши новые требования отправлены исполнителю.");
|
||||||
|
invalidate();
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const error = err as any;
|
||||||
|
toast.error(error?.response?.data?.detail ?? "Ошибка при отправке возражения")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutateAccept = useMutation({
|
||||||
|
mutationFn: () => acceptDisputeTerms(order.id, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Вы приняли условия. Спор закрыт, заказ завершен.");
|
||||||
|
invalidate();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const error = err as any;
|
||||||
|
toast.error(error?.response?.data?.detail ?? "Ошибка при принятии условий")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setActionComment("");
|
||||||
|
setActionCounterSolution("");
|
||||||
|
setActionEvidence([]);
|
||||||
|
setShowReplyForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!dispute) return null;
|
||||||
|
|
||||||
|
const canRespond = isPerformer && dispute.status === "WaitingForPerformer";
|
||||||
|
const canRebut = isCustomer && dispute.status === "WaitingForCustomer";
|
||||||
|
const canAccept = isCustomer && dispute.status === "WaitingForCustomer";
|
||||||
|
|
||||||
|
const getAuthorLabel = (authorId: string) => {
|
||||||
|
if (authorId === order.customerId) return "Заказчик (" + order.customerName + ")";
|
||||||
|
if (authorId === order.performerId) return "Исполнитель (" + order.performerName + ")";
|
||||||
|
return "Система";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] border-none p-0 overflow-hidden bg-white dark:bg-zinc-900 shadow-2xl rounded-[32px]">
|
||||||
|
<div className="bg-rose-500/10 p-6 flex items-center gap-4 border-b border-rose-500/10">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-rose-500 flex items-center justify-center shadow-lg shadow-rose-500/20">
|
||||||
|
<Gavel className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<DialogTitle className="text-xl font-bold text-rose-600 dark:text-rose-400">
|
||||||
|
Детали спора
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs text-rose-600/60 font-medium uppercase tracking-wider">Заказ #{order.id.slice(0, 8)}</p>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-rose-300 mx-1" />
|
||||||
|
<p className="text-xs font-bold text-rose-500">{DISPUTE_REASONS_MAP[dispute.reason] || dispute.reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border",
|
||||||
|
dispute.status === "WaitingForPerformer" ? "bg-amber-100 text-amber-700 border-amber-200" :
|
||||||
|
dispute.status === "WaitingForCustomer" ? "bg-blue-100 text-blue-700 border-blue-200" :
|
||||||
|
"bg-green-100 text-green-700 border-green-200"
|
||||||
|
)}>
|
||||||
|
{dispute.status === "WaitingForPerformer" ? "Ожидает исполнителя" :
|
||||||
|
dispute.status === "WaitingForCustomer" ? "Ожидает заказчика" :
|
||||||
|
"Решено"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-0 flex flex-col max-h-[75vh]">
|
||||||
|
<div className="p-8 space-y-8 overflow-y-auto custom-scrollbar flex-1">
|
||||||
|
{/* История сообщений */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
История обсуждения
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="relative space-y-8 before:absolute before:inset-0 before:ml-5 before:-translate-x-px before:h-full before:w-0.5 before:bg-gradient-to-b before:from-zinc-100 before:via-zinc-200 before:to-transparent dark:before:from-zinc-800 dark:before:via-zinc-700">
|
||||||
|
{dispute.messages.map((msg) => (
|
||||||
|
<div key={msg.id} className="relative flex items-start group">
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center justify-center h-10 w-10 rounded-2xl border-4 border-white dark:border-zinc-900 shadow-sm shrink-0 z-10",
|
||||||
|
msg.authorId === order.customerId ? "bg-blue-500 text-white" : "bg-indigo-500 text-white"
|
||||||
|
)}>
|
||||||
|
{msg.authorId === order.customerId ? <User className="h-5 w-5" /> : <ShieldAlert className="h-5 w-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 ml-4 pt-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-black uppercase tracking-wider text-muted-foreground/80">
|
||||||
|
{getAuthorLabel(msg.authorId)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||||
|
{new Date(msg.createdAt).toLocaleString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-950 rounded-2xl p-4 border border-zinc-100 dark:border-zinc-800 shadow-sm group-hover:shadow-md transition-shadow">
|
||||||
|
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{msg.text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{msg.proposedSolution && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-zinc-100 dark:border-zinc-800">
|
||||||
|
<p className="text-[10px] font-black uppercase text-rose-500/60 mb-1 tracking-wider">Предлагаемое решение</p>
|
||||||
|
<p className="text-xs font-bold text-rose-600 dark:text-rose-400 italic">
|
||||||
|
«{msg.proposedSolution}»
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg.evidence && msg.evidence.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{msg.evidence.map((url, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="h-16 w-16 rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-800 hover:ring-2 ring-primary transition-all"
|
||||||
|
>
|
||||||
|
<img src={url} alt="Evidence" className="h-full w-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Форма ответа */}
|
||||||
|
{showReplyForm ? (
|
||||||
|
<div className="animate-in slide-in-from-bottom-4 duration-300 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-950 rounded-[32px] p-6 space-y-6 border border-zinc-200 dark:border-zinc-800 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-primary">Ваш ответ</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowReplyForm(false)} className="rounded-full h-8 text-xs">
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<label className="font-bold text-zinc-700 dark:text-zinc-300 ml-2">Комментарий / Позиция</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Опишите ваши аргументы и факты..."
|
||||||
|
value={actionComment}
|
||||||
|
onChange={(e) => setActionComment(e.target.value)}
|
||||||
|
className="min-h-[100px] rounded-2xl bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<label className="font-bold text-zinc-700 dark:text-zinc-300 ml-2">Ваше предложение по решению</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Например: 'Согласен на возврат 50%' или 'Готов доделать завтра'"
|
||||||
|
value={actionCounterSolution}
|
||||||
|
onChange={(e) => setActionCounterSolution(e.target.value)}
|
||||||
|
className="min-h-[80px] rounded-2xl bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[11px] font-bold text-zinc-700 dark:text-zinc-300 ml-2 uppercase tracking-wider">Материалы и доказательства</label>
|
||||||
|
<div className="p-4 rounded-3xl bg-white dark:bg-zinc-900 border border-dashed border-zinc-300 dark:border-zinc-700">
|
||||||
|
<ImageUploader
|
||||||
|
value={actionEvidence}
|
||||||
|
onChange={setActionEvidence}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full h-12 rounded-2xl bg-primary hover:bg-primary/90 text-white font-bold shadow-lg shadow-primary/20"
|
||||||
|
onClick={() => {
|
||||||
|
if (canRespond) mutateRespond.mutate();
|
||||||
|
else if (canRebut) mutateRebut.mutate();
|
||||||
|
}}
|
||||||
|
disabled={mutateRespond.isPending || mutateRebut.isPending || !actionComment}
|
||||||
|
>
|
||||||
|
{(mutateRespond.isPending || mutateRebut.isPending) ? "Отправка..." : "Отправить ответ"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-2xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-amber-500 shrink-0" />
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400 font-medium leading-relaxed">
|
||||||
|
{dispute.status === "WaitingForPerformer" && !isPerformer ? "Ожидаем ответ исполнителя. У него есть время на подготовку материалов." :
|
||||||
|
dispute.status === "WaitingForCustomer" && !isCustomer ? "Ожидаем решение заказчика." :
|
||||||
|
"Вы можете добавить материалы или принять условия, чтобы закрыть спор."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showReplyForm && (
|
||||||
|
<div className="p-8 border-t border-zinc-100 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-950/20 flex flex-col sm:flex-row gap-4">
|
||||||
|
{(canRespond || canRebut) && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowReplyForm(true)}
|
||||||
|
className="flex-1 h-14 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white font-bold shadow-xl shadow-indigo-600/20"
|
||||||
|
>
|
||||||
|
<FileText className="h-5 w-5 mr-2" />
|
||||||
|
{canRespond ? "Ответить на претензию" : "Возразить / Добавить материалы"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAccept && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Вы уверены, что хотите принять условия исполнителя и закрыть спор? Заказ будет завершен.")) {
|
||||||
|
mutateAccept.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 h-14 rounded-2xl border-green-500/20 text-green-600 hover:bg-green-600 hover:text-white transition-all font-bold shadow-xl shadow-green-600/5"
|
||||||
|
>
|
||||||
|
<Check className="h-5 w-5 mr-2" />
|
||||||
|
Принять условия и закрыть спор
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-14 rounded-2xl font-bold text-muted-foreground"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ImageUploader } from "@/components/ui/image-uploader";
|
||||||
|
import { AlertTriangle, Info, ShieldAlert } from 'lucide-react';
|
||||||
|
import { disputeOrder } from '@/shared/api/orders';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface DisputeModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
orderId: string;
|
||||||
|
customerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASONS = [
|
||||||
|
{ value: "WorkNotPerformed", label: "Работы не выполнены" },
|
||||||
|
{ value: "NotFullVolume", label: "Выполнены не в полном объеме" },
|
||||||
|
{ value: "PoorQuality", label: "Выполнены некачественно" },
|
||||||
|
{ value: "DeadlineViolated", label: "Нарушен срок" },
|
||||||
|
{ value: "PriceIncreased", label: "Увеличилась стоимость" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DisputeModal = ({ isOpen, onClose, orderId, customerId }: DisputeModalProps) => {
|
||||||
|
const [reason, setReason] = useState<string>("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [proposedSolution, setProposedSolution] = useState("");
|
||||||
|
const [evidence, setEvidence] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => disputeOrder(orderId, {
|
||||||
|
customerId,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
reason: reason as any,
|
||||||
|
description,
|
||||||
|
proposedSolution,
|
||||||
|
evidence
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Спор открыт. У исполнителя есть 3 дня для ответа.");
|
||||||
|
qc.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
onClose();
|
||||||
|
// Reset
|
||||||
|
setReason("");
|
||||||
|
setDescription("");
|
||||||
|
setProposedSolution("");
|
||||||
|
setEvidence([]);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any;
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const data = error?.response?.data;
|
||||||
|
|
||||||
|
// Используем console.info чтобы Next.js не перехватывал console.error и не крашил экран!
|
||||||
|
console.info(`Dispute backend error (Status: ${status}):`, data || error?.message);
|
||||||
|
|
||||||
|
let errMsg = `Ошибка при открытии спора (Код: ${status || 'неизвестно'})`;
|
||||||
|
if (data) {
|
||||||
|
if (typeof data === 'string') errMsg = data;
|
||||||
|
else if (data.message) errMsg = data.message;
|
||||||
|
else if (data.Message) errMsg = data.Message;
|
||||||
|
else if (data.detail) errMsg = data.detail;
|
||||||
|
else if (data.errors) {
|
||||||
|
const firstErr = Object.values(data.errors)[0];
|
||||||
|
if (Array.isArray(firstErr)) errMsg = firstErr[0] as string;
|
||||||
|
} else if (Object.keys(data).length > 0) {
|
||||||
|
errMsg = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
} else if (error?.message) {
|
||||||
|
errMsg = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errMsg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!reason) return toast.error("Выберите причину спора");
|
||||||
|
if (!description) return toast.error("Опишите проблему");
|
||||||
|
if (!proposedSolution) return toast.error("Предложите решение");
|
||||||
|
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] border-none p-0 overflow-hidden bg-white dark:bg-zinc-900 shadow-2xl rounded-[32px]">
|
||||||
|
<div className="bg-rose-500/10 p-6 flex items-center gap-4 border-b border-rose-500/10">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-rose-500 flex items-center justify-center shadow-lg shadow-rose-500/20">
|
||||||
|
<ShieldAlert className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold text-rose-600 dark:text-rose-400">
|
||||||
|
Открытие спора
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-xs text-rose-600/60 font-medium uppercase tracking-wider">Заказ #{orderId.slice(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300 px-1">Причина претензии</label>
|
||||||
|
<Select value={reason} onValueChange={setReason}>
|
||||||
|
<SelectTrigger className="h-12 rounded-2xl border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950">
|
||||||
|
<SelectValue placeholder="Выберите подходящую причину" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-2xl border-zinc-200 dark:border-zinc-800">
|
||||||
|
{REASONS.map(r => (
|
||||||
|
<SelectItem key={r.value} value={r.value} className="rounded-xl my-1 cursor-pointer">
|
||||||
|
{r.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300 px-1">Что случилось? (Описание)</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Опишите подробно суть претензии..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="min-h-[100px] rounded-2xl bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300 px-1">Желаемое решение</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Чего вы ожидаете: возврат средств, переделка или скидка?"
|
||||||
|
value={proposedSolution}
|
||||||
|
onChange={(e) => setProposedSolution(e.target.value)}
|
||||||
|
className="min-h-[80px] rounded-2xl bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Доказательства</label>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">макс. 5 фото, 2 видео</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-[24px] bg-zinc-50 dark:bg-zinc-950 border border-dashed border-zinc-300 dark:border-zinc-700">
|
||||||
|
<ImageUploader
|
||||||
|
value={evidence}
|
||||||
|
onChange={setEvidence}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-[11px] text-muted-foreground italic">
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
<span>Фото до 5МБ, Видео до 100МБ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-2xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0" />
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400 font-medium leading-relaxed">
|
||||||
|
У исполнителя будет 3 дня, чтобы ответить на ваш запрос. Если вы не договоритесь, вмешается поддержка.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-8 border-t border-zinc-100 dark:border-zinc-800 flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-12 rounded-2xl font-bold text-muted-foreground"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-12 rounded-2xl bg-rose-600 hover:bg-rose-700 text-white font-bold shadow-lg shadow-rose-600/25"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Отправка..." : "Открыть спор"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ImageUploader } from "@/components/ui/image-uploader";
|
||||||
|
import { Info, ShieldCheck, Gavel } from 'lucide-react';
|
||||||
|
import { respondToDispute } from '@/shared/api/orders';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface DisputeResponseModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
orderId: string;
|
||||||
|
performerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisputeResponseModal = ({ isOpen, onClose, orderId, performerId }: DisputeResponseModalProps) => {
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [counterSolution, setCounterSolution] = useState("");
|
||||||
|
const [evidence, setEvidence] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => respondToDispute(orderId, {
|
||||||
|
performerId,
|
||||||
|
position: comment,
|
||||||
|
evidence,
|
||||||
|
counterSolution: counterSolution || undefined
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Ответ на спор отправлен. Ожидайте решения или вмешательства поддержки.");
|
||||||
|
qc.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
onClose();
|
||||||
|
// Reset
|
||||||
|
setComment("");
|
||||||
|
setCounterSolution("");
|
||||||
|
setEvidence([]);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
|
toast.error(error?.response?.data?.detail ?? "Ошибка при ответе на спор");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!comment) return toast.error("Опишите вашу позицию");
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] border-none p-0 overflow-hidden bg-white dark:bg-zinc-900 shadow-2xl rounded-[32px]">
|
||||||
|
<div className="bg-indigo-500/10 p-6 flex items-center gap-4 border-b border-indigo-500/10">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-indigo-500 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||||
|
<Gavel className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
|
Ответ на претензию
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-xs text-indigo-600/60 font-medium uppercase tracking-wider">Заказ #{orderId.slice(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300 px-1">Ваша позиция</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Опишите ваше видение ситуации и почему претензия необоснованна (или частично обоснованна)..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="min-h-[120px] rounded-2xl bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300 px-1">Ваше предложение по решению (необязательно)</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Например: 'Готов переделать бесплатно' или 'Предлагаю скидку 20%'"
|
||||||
|
value={counterSolution}
|
||||||
|
onChange={(e) => setCounterSolution(e.target.value)}
|
||||||
|
className="min-h-[80px] rounded-2xl bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<label className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Доказательства выполнения</label>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">макс. 5 фото / видео</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-[24px] bg-zinc-50 dark:bg-zinc-950 border border-dashed border-zinc-300 dark:border-zinc-700">
|
||||||
|
<ImageUploader
|
||||||
|
value={evidence}
|
||||||
|
onChange={setEvidence}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-[11px] text-muted-foreground italic">
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
<span>Приложите фото/видео отчет о проделанной работе</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-blue-500 shrink-0" />
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-400 font-medium leading-relaxed">
|
||||||
|
После вашего ответа спор перейдет на стадию рассмотрения. Рекомендуется связаться с заказчиком в чате для мирного урегулирования.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-8 border-t border-zinc-100 dark:border-zinc-800 flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-12 rounded-2xl font-bold text-muted-foreground"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-12 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white font-bold shadow-lg shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Отправка..." : "Ответить на спор"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface RatingStatsProps {
|
||||||
|
userId: string;
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RatingStats = ({ userId, className, size = 18 }: RatingStatsProps) => {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['reputation', userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await fetch(`/api/reputation/${userId}/rating`);
|
||||||
|
if (!resp.ok) return { averageRating: 0, totalReviews: 0 };
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5, // Кэшируем рейтинг на 5 минут
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div className="animate-pulse w-24 h-6 bg-slate-200 rounded"></div>;
|
||||||
|
if (!data || data.totalReviews === 0) return (
|
||||||
|
<div className={cn("text-gray-400 text-sm italic", className)}>Нет отзывов</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-1.5", className)}>
|
||||||
|
<Star size={size} className="fill-yellow-400 text-yellow-400" />
|
||||||
|
<span className="font-bold text-gray-900">{data.averageRating}</span>
|
||||||
|
<span className="text-gray-500 text-sm">({data.totalReviews} отзывов)</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ImageUploader } from "@/components/ui/image-uploader";
|
||||||
|
import { MessageSquarePlus } from 'lucide-react';
|
||||||
|
import { useAddReviewAddition } from '@/shared/api/reputation';
|
||||||
|
|
||||||
|
interface ReviewAdditionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
reviewId: string;
|
||||||
|
authorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewAdditionModal = ({ isOpen, onClose, reviewId, authorId }: ReviewAdditionModalProps) => {
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [mediaUrls, setMediaUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const mutation = useAddReviewAddition();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!comment) return toast.error("Добавьте текст дополнения");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mutation.mutateAsync({
|
||||||
|
reviewId,
|
||||||
|
authorId,
|
||||||
|
comment,
|
||||||
|
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined
|
||||||
|
});
|
||||||
|
toast.success("Отзыв дополнен!");
|
||||||
|
onClose();
|
||||||
|
setComment("");
|
||||||
|
setMediaUrls([]);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
|
toast.error(error?.response?.data?.detail ?? "Ошибка при дополнении отзыва");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] border-none p-0 overflow-hidden bg-white dark:bg-zinc-900 shadow-2xl rounded-[32px]">
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-600 mb-4 transition-transform hover:scale-110">
|
||||||
|
<MessageSquarePlus className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-2xl font-bold tracking-tight">Дополнить отзыв</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground pt-1">
|
||||||
|
Вы можете добавить новые подробности или фото к вашему отзыву. Оценка останется прежней.
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold px-1">Ваше дополнение</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Напишите, что вы хотели добавить..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="resize-none min-h-[120px] rounded-[24px] bg-zinc-50 dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 transition-all focus:ring-2 focus:ring-indigo-500/20 p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-semibold px-1">Дополнительные фото</label>
|
||||||
|
<div className="p-1">
|
||||||
|
<ImageUploader
|
||||||
|
value={mediaUrls}
|
||||||
|
onChange={setMediaUrls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-4 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 h-12 rounded-2xl font-semibold text-muted-foreground"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-12 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white font-bold shadow-lg shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Добавление..." : "Дополнить"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { User, MessageSquarePlus } from 'lucide-react';
|
||||||
|
import { ReviewDto } from '@/shared/api/reputation';
|
||||||
|
import { StarRating } from './StarRating';
|
||||||
|
import { useSessionStore } from '@/entities/session/store';
|
||||||
|
import { ReviewAdditionModal } from './ReviewAdditionModal';
|
||||||
|
// removed unused cn import
|
||||||
|
|
||||||
|
interface ReviewCardProps {
|
||||||
|
review: ReviewDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewCard = ({ review }: ReviewCardProps) => {
|
||||||
|
const { user } = useSessionStore();
|
||||||
|
const isAuthor = user?.id === review.authorId;
|
||||||
|
const [isAdditionModalOpen, setIsAdditionModalOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group rounded-3xl border bg-card/60 backdrop-blur-md p-6 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-500 overflow-hidden border-border/50 space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-600">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold">
|
||||||
|
{review.author ? `${review.author.firstName} ${review.author.lastName}`.trim() : "Анонимный клиент"}
|
||||||
|
</p>
|
||||||
|
<StarRating rating={review.rating} size={14} className="mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase font-black opacity-40">
|
||||||
|
{new Date(review.createdAt).toLocaleDateString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm leading-relaxed text-foreground/80 italic">
|
||||||
|
«{review.comment || (review.isAutoGenerated ? "Система автоматически подтвердила выполнение работы." : "Оценка без комментария")}»
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{review.mediaUrls && review.mediaUrls.length > 0 && (
|
||||||
|
<div className="flex gap-2.5 overflow-x-auto pb-2 scrollbar-none">
|
||||||
|
{review.mediaUrls.map((url, i) => (
|
||||||
|
<div key={i} className="relative w-24 h-24 rounded-2xl overflow-hidden border border-border/40 shrink-0 group/img">
|
||||||
|
<img src={url} alt="Review attachment" className="w-full h-full object-cover transition-transform duration-500 group-hover/img:scale-110" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Дополнения */}
|
||||||
|
{review.additions && review.additions.length > 0 && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-border/20">
|
||||||
|
{review.additions.map((addition, i) => (
|
||||||
|
<div key={i} className="pl-6 border-l-2 border-indigo-500/20 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-black uppercase text-indigo-500/60 tracking-wider">Дополнение к отзыву</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/40">{new Date(addition.createdAt).toLocaleDateString("ru-RU")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-foreground/70 leading-relaxed italic">
|
||||||
|
«{addition.comment}»
|
||||||
|
</p>
|
||||||
|
{addition.mediaUrls && addition.mediaUrls.length > 0 && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{addition.mediaUrls.map((url, j) => (
|
||||||
|
<div key={j} className="relative w-16 h-16 rounded-xl overflow-hidden border border-border/40 shrink-0">
|
||||||
|
<img src={url} alt="Addition attachment" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthor && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAdditionModalOpen(true)}
|
||||||
|
className="flex items-center gap-2 text-[11px] font-bold text-indigo-500 hover:text-indigo-600 transition-colors uppercase tracking-widest px-1 py-1"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="w-3.5 h-3.5" />
|
||||||
|
Дополнить отзыв
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReviewAdditionModal
|
||||||
|
isOpen={isAdditionModalOpen}
|
||||||
|
onClose={() => setIsAdditionModalOpen(false)}
|
||||||
|
reviewId={review.id}
|
||||||
|
authorId={user?.id || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLeaveReview } from '@/shared/api/reputation';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { StarRating } from "./StarRating";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ImageUploader } from "@/components/ui/image-uploader";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ReviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
orderId: string;
|
||||||
|
offerId: string;
|
||||||
|
authorId: string;
|
||||||
|
targetId: string;
|
||||||
|
targetName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReviewModal = ({ isOpen, onClose, orderId, offerId, authorId, targetId, targetName }: ReviewModalProps) => {
|
||||||
|
const [rating, setRating] = useState(5);
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [mediaUrls, setMediaUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const mutation = useLeaveReview();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (mutation.isPending) return;
|
||||||
|
|
||||||
|
console.log('Submitting review:', { orderId, offerId, authorId, targetId, rating, comment, mediaUrls });
|
||||||
|
try {
|
||||||
|
const result = await mutation.mutateAsync({
|
||||||
|
orderId,
|
||||||
|
offerId,
|
||||||
|
authorId,
|
||||||
|
targetId,
|
||||||
|
rating,
|
||||||
|
comment: comment || undefined,
|
||||||
|
mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined
|
||||||
|
});
|
||||||
|
console.log('Review submitted successfully:', result);
|
||||||
|
toast.success('Отзыв успешно отправлен!');
|
||||||
|
onClose();
|
||||||
|
// Reset state
|
||||||
|
setRating(5);
|
||||||
|
setComment('');
|
||||||
|
setMediaUrls([]);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
|
console.error('Review submit error:', error);
|
||||||
|
const errorMessage = error?.response?.data?.detail
|
||||||
|
?? error?.response?.data?.message
|
||||||
|
?? error?.message
|
||||||
|
?? 'Ошибка при отправке отзыва';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] border-none p-0 overflow-hidden bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl shadow-2xl rounded-[32px]">
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
Оцените работу {targetName ? <span className="text-indigo-600 dark:text-indigo-400">{targetName}</span> : 'мастера'}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 pt-1">
|
||||||
|
Ваш отзыв поможет другим пользователям сделать правильный выбор
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Rating Selection */}
|
||||||
|
<div className="flex flex-col items-center justify-center p-6 bg-zinc-100/50 dark:bg-white/5 rounded-[24px] border border-zinc-200/50 dark:border-white/5">
|
||||||
|
<span className="text-sm font-semibold uppercase tracking-wider text-zinc-400 mb-4">Насколько вы довольны?</span>
|
||||||
|
<StarRating
|
||||||
|
rating={rating}
|
||||||
|
onRatingChange={setRating}
|
||||||
|
interactive={true}
|
||||||
|
size={40}
|
||||||
|
className="gap-3"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"text-lg font-bold transition-colors duration-300",
|
||||||
|
rating >= 4 ? "text-green-500" : rating >= 3 ? "text-indigo-500" : "text-rose-500"
|
||||||
|
)}>
|
||||||
|
{rating === 5 && "Превосходно!"}
|
||||||
|
{rating === 4 && "Очень хорошо"}
|
||||||
|
{rating === 3 && "Нормально"}
|
||||||
|
{rating === 2 && "Плохо"}
|
||||||
|
{rating === 1 && "Ужасно"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 px-1">Ваш комментарий</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Расскажите, что вам понравилось или что можно улучшить..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
className="resize-none min-h-[120px] rounded-[20px] bg-white dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-base p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Uploader */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 px-1">Фотографии работ</label>
|
||||||
|
<div className="p-1">
|
||||||
|
<ImageUploader
|
||||||
|
value={mediaUrls}
|
||||||
|
onChange={setMediaUrls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex gap-3 sm:gap-0 pt-4 pb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-14 rounded-2xl font-semibold text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="flex-1 h-14 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-lg shadow-lg shadow-indigo-500/25 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
<span>Отправка...</span>
|
||||||
|
</div>
|
||||||
|
) : 'Опубликовать'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getRatingStarColor } from '@/shared/lib/ratingColors';
|
||||||
|
|
||||||
|
interface StarRatingProps {
|
||||||
|
rating: number;
|
||||||
|
onRatingChange?: (rating: number) => void;
|
||||||
|
interactive?: boolean;
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StarRating = ({
|
||||||
|
rating,
|
||||||
|
onRatingChange,
|
||||||
|
interactive = false,
|
||||||
|
className,
|
||||||
|
size = 24
|
||||||
|
}: StarRatingProps) => {
|
||||||
|
const [hoverRating, setHoverRating] = useState(0);
|
||||||
|
|
||||||
|
const getStarColor = (starRating: number) => getRatingStarColor(starRating);
|
||||||
|
|
||||||
|
const activeRating = hoverRating || rating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-1", className)}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
interactive ? "cursor-pointer" : "cursor-default",
|
||||||
|
activeRating >= star
|
||||||
|
? getStarColor(activeRating)
|
||||||
|
: "text-gray-300"
|
||||||
|
)}
|
||||||
|
onClick={() => interactive && onRatingChange?.(star)}
|
||||||
|
onMouseEnter={() => interactive && setHoverRating(star)}
|
||||||
|
onMouseLeave={() => interactive && setHoverRating(0)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Upload, User, Check, X, Edit2 } from "lucide-react"
|
import { User, Check, X, Edit2 } from "lucide-react"
|
||||||
import Cropper from "react-easy-crop"
|
import Cropper from "react-easy-crop"
|
||||||
import { api } from "@/shared/api/axios"
|
import { api } from "@/shared/api/axios"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -38,7 +38,7 @@ export function AvatarUploader({ currentAvatarUrl, onUploadSuccess }: AvatarUplo
|
|||||||
setPreview(currentAvatarUrl)
|
setPreview(currentAvatarUrl)
|
||||||
}, [currentAvatarUrl])
|
}, [currentAvatarUrl])
|
||||||
|
|
||||||
const onCropComplete = React.useCallback((_: any, croppedAreaPixels: CropArea) => {
|
const onCropComplete = React.useCallback((_: unknown, croppedAreaPixels: CropArea) => {
|
||||||
setCroppedAreaPixels(croppedAreaPixels)
|
setCroppedAreaPixels(croppedAreaPixels)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
+28
-21
@@ -1,29 +1,36 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
const Badge = React.forwardRef<
|
import { cn } from "@/lib/utils"
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "secondary" | "destructive" | "outline" }
|
const badgeVariants = cva(
|
||||||
>(({ className, variant = "default", ...props }, ref) => {
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
const variants = {
|
{
|
||||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
variants: {
|
||||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
variant: {
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
variants[variant],
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
Badge.displayName = "Badge"
|
|
||||||
|
|
||||||
export { Badge }
|
export { Badge, badgeVariants }
|
||||||
|
|||||||
@@ -2,28 +2,29 @@ import * as React from "react"
|
|||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "h-10 w-10",
|
icon: "h-9 w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
+10
-12
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -8,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -29,25 +30,22 @@ const CardHeader = React.forwardRef<
|
|||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h3
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalP
|
|||||||
toast.success("Пароль успешно изменён")
|
toast.success("Пароль успешно изменён")
|
||||||
reset()
|
reset()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
toast.error(error?.response?.data?.message || "Ошибка при смене пароля")
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const err = error as any
|
||||||
|
toast.error(err?.response?.data?.message || "Ошибка при смене пароля")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
@@ -13,13 +13,13 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
className={cn("flex items-center justify-center text-current")}
|
className={cn("grid place-content-center text-current")}
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
|
|||||||
+86
-101
@@ -1,75 +1,76 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
interface DialogProps {
|
import { cn } from "@/lib/utils"
|
||||||
open?: boolean
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dialog({ open, onOpenChange, children }: DialogProps) {
|
const Dialog = DialogPrimitive.Root
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
// Передаем onOpenChange через контекст или рендерим children с пропсами
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
children: React.ReactNode
|
|
||||||
onClose?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogContent({ children, className, onClose, ...props }: DialogContentProps) {
|
const DialogClose = DialogPrimitive.Close
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Content */}
|
const DialogOverlay = React.forwardRef<
|
||||||
<div
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 w-full max-w-lg mx-4 bg-white dark:bg-zinc-900 rounded-lg shadow-lg",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* Кнопка закрытия (X) */}
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Закрыть</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
</div>
|
<X className="h-4 w-4" />
|
||||||
)
|
<span className="sr-only">Close</span>
|
||||||
}
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> { }
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
export function DialogHeader({ className, ...props }: DialogHeaderProps) {
|
...props
|
||||||
return (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> { }
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
export function DialogFooter({ className, ...props }: DialogFooterProps) {
|
...props
|
||||||
return (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
@@ -77,61 +78,45 @@ export function DialogFooter({ className, ...props }: DialogFooterProps) {
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> { }
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
export function DialogTitle({ className, ...props }: DialogTitleProps) {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
return (
|
>(({ className, ...props }, ref) => (
|
||||||
<h2
|
<DialogPrimitive.Title
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> { }
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
export function DialogDescription({ className, ...props }: DialogDescriptionProps) {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
return (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export {
|
||||||
asChild?: boolean
|
Dialog,
|
||||||
}
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
export function DialogClose({ className, asChild, ...props }: DialogCloseProps) {
|
DialogTrigger,
|
||||||
// Если asChild=true, нужно использовать Slot из radix-ui
|
DialogClose,
|
||||||
if (asChild) {
|
DialogContent,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
DialogHeader,
|
||||||
const { Slot } = require("@radix-ui/react-slot") as { Slot: React.ComponentType<any> }
|
DialogFooter,
|
||||||
return (
|
DialogTitle,
|
||||||
<Slot
|
DialogDescription,
|
||||||
className={cn(
|
|
||||||
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Закрыть</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
@@ -27,14 +27,14 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
))
|
||||||
DropdownMenuSubTrigger.displayName =
|
DropdownMenuSubTrigger.displayName =
|
||||||
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -65,7 +65,8 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
"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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -83,7 +84,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
+18
-17
@@ -1,17 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
ControllerProps,
|
|
||||||
FieldPath,
|
|
||||||
FieldValues,
|
|
||||||
FormProvider,
|
FormProvider,
|
||||||
useFormContext,
|
useFormContext,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
} from "react-hook-form"
|
} from "react-hook-form"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider
|
||||||
@@ -23,9 +24,7 @@ type FormFieldContextValue<
|
|||||||
name: TName
|
name: TName
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
||||||
{} as FormFieldContextValue
|
|
||||||
)
|
|
||||||
|
|
||||||
const FormField = <
|
const FormField = <
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
@@ -45,12 +44,16 @@ const useFormField = () => {
|
|||||||
const itemContext = React.useContext(FormItemContext)
|
const itemContext = React.useContext(FormItemContext)
|
||||||
const { getFieldState, formState } = useFormContext()
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
|
||||||
|
|
||||||
if (!fieldContext) {
|
if (!fieldContext) {
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!itemContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormItem>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
const { id } = itemContext
|
const { id } = itemContext
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -67,9 +70,7 @@ type FormItemContextValue = {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
||||||
{} as FormItemContextValue
|
|
||||||
)
|
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
const FormItem = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -86,8 +87,8 @@ const FormItem = React.forwardRef<
|
|||||||
FormItem.displayName = "FormItem"
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
const FormLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof Label>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof Label>
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { error, formItemId } = useFormField()
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ const FormDescription = React.forwardRef<
|
|||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={formDescriptionId}
|
id={formDescriptionId}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -146,7 +147,7 @@ const FormMessage = React.forwardRef<
|
|||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => {
|
||||||
const { error, formMessageId } = useFormField()
|
const { error, formMessageId } = useFormField()
|
||||||
const body = error ? String(error?.message) : children
|
const body = error ? String(error?.message ?? "") : children
|
||||||
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return null
|
return null
|
||||||
@@ -156,7 +157,7 @@ const FormMessage = React.forwardRef<
|
|||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={formMessageId}
|
id={formMessageId}
|
||||||
className={cn("text-sm font-medium text-destructive", className)}
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useRef, useState } from "react";
|
|||||||
import { uploadOfferImage } from "@/shared/api/catalog";
|
import { uploadOfferImage } from "@/shared/api/catalog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { X, ImagePlus, Loader2, ArrowLeft, ArrowRight } from "lucide-react";
|
import { X, ImagePlus, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
value: string[];
|
value: string[];
|
||||||
@@ -37,12 +37,12 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
|
|||||||
newUrls.push(url);
|
newUrls.push(url);
|
||||||
}
|
}
|
||||||
onChange(newUrls);
|
onChange(newUrls);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Не удалось загрузить изображения");
|
toast.error("Не удалось загрузить изображения");
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
}
|
}
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
const removeImage = (index: number) => {
|
||||||
@@ -57,7 +57,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
|
|||||||
// To make drag image look clean, optionally set drag image here
|
// To make drag image look clean, optionally set drag image here
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
};
|
};
|
||||||
@@ -87,7 +87,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
|
|||||||
className={`flex flex-col gap-2 ${draggedIndex === i ? 'opacity-50' : 'opacity-100'}`}
|
className={`flex flex-col gap-2 ${draggedIndex === i ? 'opacity-50' : 'opacity-100'}`}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, i)}
|
onDragStart={(e) => handleDragStart(e, i)}
|
||||||
onDragOver={(e) => handleDragOver(e, i)}
|
onDragOver={(e) => handleDragOver(e)}
|
||||||
onDrop={(e) => handleDrop(e, i)}
|
onDrop={(e) => handleDrop(e, i)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -60,7 +59,9 @@ export function LocationModal({
|
|||||||
await updateLocation(data.location || "", data.currentLocation || "")
|
await updateLocation(data.location || "", data.currentLocation || "")
|
||||||
toast.success("Локация успешно обновлена")
|
toast.success("Локация успешно обновлена")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (error: any) {
|
} catch (err: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
toast.error(error?.response?.data?.message || "Ошибка при обновлении локации")
|
toast.error(error?.response?.data?.message || "Ошибка при обновлении локации")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
import { Circle } from "lucide-react"
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const RadioGroup = React.forwardRef<
|
const RadioGroup = React.forwardRef<
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
@@ -28,13 +28,13 @@ const RadioGroupItem = React.forwardRef<
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||||
</RadioGroupPrimitive.Indicator>
|
</RadioGroupPrimitive.Indicator>
|
||||||
</RadioGroupPrimitive.Item>
|
</RadioGroupPrimitive.Item>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,18 +3,24 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Star } from "lucide-react"
|
import { Star } from "lucide-react"
|
||||||
|
|
||||||
|
import { getRatingColor } from "@/shared/lib/ratingColors"
|
||||||
|
|
||||||
interface RatingProps {
|
interface RatingProps {
|
||||||
value: number
|
value: number
|
||||||
showValue?: boolean
|
showValue?: boolean
|
||||||
size?: "sm" | "md" | "lg"
|
size?: "sm" | "md" | "lg"
|
||||||
|
showCount?: boolean
|
||||||
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
export function Rating({ value, showValue = true, size = "md", showCount = false, count }: RatingProps) {
|
||||||
// Определяем цвет звезды на основе рейтинга
|
// Определяем цвет звезды на основе рейтинга (3 цвета как в профиле)
|
||||||
const getStarColor = () => {
|
const getStarStyle = () => getRatingColor(value);
|
||||||
if (value >= 4) return "text-green-600 dark:text-green-500"
|
|
||||||
if (value >= 3) return "text-blue-600 dark:text-blue-500"
|
const hasNoRating = value === 0 || (showCount && count === 0);
|
||||||
return "text-red-600 dark:text-red-500"
|
|
||||||
|
if (hasNoRating) {
|
||||||
|
return <span className="text-sm text-muted-foreground italic font-medium">Без рейтинга</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Размеры иконки
|
// Размеры иконки
|
||||||
@@ -26,7 +32,6 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
|||||||
|
|
||||||
// Создаем массив из 5 звезд с процентом заполнения
|
// Создаем массив из 5 звезд с процентом заполнения
|
||||||
const stars = Array.from({ length: 5 }, (_, index) => {
|
const stars = Array.from({ length: 5 }, (_, index) => {
|
||||||
const starValue = index + 1
|
|
||||||
const fillPercentage = Math.max(0, Math.min(100, (value - index) * 100))
|
const fillPercentage = Math.max(0, Math.min(100, (value - index) * 100))
|
||||||
|
|
||||||
return { index, fillPercentage }
|
return { index, fillPercentage }
|
||||||
@@ -48,7 +53,7 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
|||||||
style={{ width: `${fillPercentage}%` }}
|
style={{ width: `${fillPercentage}%` }}
|
||||||
>
|
>
|
||||||
<Star
|
<Star
|
||||||
className={`${sizeClasses[size]} ${getStarColor()} fill-current`}
|
className={`${sizeClasses[size]} ${getStarStyle()}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,11 +61,22 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Числовое значение справа */}
|
{/* Числовое значение справа */}
|
||||||
{showValue && (
|
{showValue && value > 0 && (
|
||||||
<span className={`font-semibold ${getStarColor()}`}>
|
<span className={`font-semibold ${getStarStyle().split(' ')[0]}`}>
|
||||||
{value.toFixed(2)}
|
{value.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{value === 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground italic">Без рейтинга</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Количество отзывов */}
|
||||||
|
{showCount && count !== undefined && count > 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
({count} {count === 1 ? 'отзыв' : count >= 2 && count <= 4 ? 'отзыва' : 'отзывов'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,28 +55,6 @@ export function RichTextEditor({ content, onChange, placeholder, maxLength }: Ri
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLinkClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const href = e.currentTarget.getAttribute("href")
|
|
||||||
if (!href) return
|
|
||||||
|
|
||||||
// Проверяем, является ли ссылка внешней
|
|
||||||
const isExternal = href.startsWith("http://") || href.startsWith("https://")
|
|
||||||
|
|
||||||
if (isExternal) {
|
|
||||||
const domain = new URL(href).hostname
|
|
||||||
const confirmed = confirm(
|
|
||||||
`Вы переходите на внешний ресурс: ${domain}\n\n` +
|
|
||||||
`Ссылка: ${href}\n\n` +
|
|
||||||
`Вы уверены, что хотите перейти?`
|
|
||||||
)
|
|
||||||
if (confirmed) {
|
|
||||||
window.open(href, "_blank", "noopener,noreferrer")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.open(href, "_blank")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setLink = useCallback(() => {
|
const setLink = useCallback(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|||||||
@@ -184,7 +184,9 @@ export function ScheduleModal({
|
|||||||
await updateSchedule(isAlwaysReady, Object.keys(scheduleData).length > 0 ? scheduleData : undefined)
|
await updateSchedule(isAlwaysReady, Object.keys(scheduleData).length > 0 ? scheduleData : undefined)
|
||||||
toast.success("Расписание успешно обновлено")
|
toast.success("Расписание успешно обновлено")
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} catch (error: any) {
|
} catch (err: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const error = err as any
|
||||||
toast.error(error?.response?.data?.message || "Ошибка при обновлении расписания")
|
toast.error(error?.response?.data?.message || "Ошибка при обновлении расписания")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
|
||||||
position === "popper" &&
|
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",
|
"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
|
className
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
const Slider = React.forwardRef<
|
||||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
@@ -16,10 +17,10 @@ const Slider = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
))
|
))
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
description: "group-[.toaster]:text-muted-foreground",
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
actionButton:
|
actionButton:
|
||||||
"group-[.toaster]:bg-primary group-[.toaster]:text-primary-foreground",
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
cancelButton:
|
cancelButton:
|
||||||
"group-[.toaster]:bg-muted group-[.toaster]:text-muted-foreground",
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,46 +1,29 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
interface SwitchProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
import { cn } from "@/lib/utils"
|
||||||
checked?: boolean
|
|
||||||
onCheckedChange?: (checked: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const Switch = React.forwardRef<
|
||||||
* Переключатель (тумблер) — аналог Shadcn Switch.
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
* Используется для тумблера «На линии» в шапке.
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
*/
|
>(({ className, ...props }, ref) => (
|
||||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
<SwitchPrimitives.Root
|
||||||
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
ref={ref}
|
|
||||||
onClick={() => onCheckedChange?.(!checked)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
checked ? "bg-green-500" : "bg-input",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<span
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
checked ? "translate-x-5" : "translate-x-0"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</SwitchPrimitives.Root>
|
||||||
)
|
))
|
||||||
}
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
)
|
|
||||||
Switch.displayName = "Switch"
|
|
||||||
|
|
||||||
export { Switch }
|
export { Switch }
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface TextareaProps
|
const Textarea = React.forwardRef<
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
>(({ className, ...props }, ref) => {
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
Textarea.displayName = "Textarea"
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface User {
|
|||||||
currentLocation?: string;
|
currentLocation?: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
status?: string;
|
||||||
competencies: string[];
|
competencies: string[];
|
||||||
workSchedule?: {
|
workSchedule?: {
|
||||||
isAlwaysReady: boolean;
|
isAlwaysReady: boolean;
|
||||||
@@ -35,7 +36,7 @@ interface SessionState {
|
|||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
login: (phone: string, password: string) => Promise<void>;
|
login: (phone: string, password: string) => Promise<void>;
|
||||||
register: (data: any) => Promise<void>;
|
register: (data: unknown) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
getProfile: () => Promise<void>;
|
getProfile: () => Promise<void>;
|
||||||
updateProfile: (data: { firstName: string, lastName: string, patronymic?: string, companyName?: string, inn?: string, description?: string }) => Promise<void>;
|
updateProfile: (data: { firstName: string, lastName: string, patronymic?: string, companyName?: string, inn?: string, description?: string }) => Promise<void>;
|
||||||
@@ -100,12 +101,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (data: any) => {
|
register: async (data: unknown) => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
// Убираем confirmPassword перед отправкой на бэкенд
|
// Убираем confirmPassword перед отправкой на бэкенд
|
||||||
const { confirmPassword, ...registerData } = data;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await api.post("/auth/register", registerData);
|
const registerData = { ...(data as Record<string, any>) };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { confirmPassword, ...payload } = registerData;
|
||||||
|
await api.post("/auth/register", payload);
|
||||||
} finally {
|
} finally {
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { useSessionStore } from "@/entities/session/store"
|
import { useSessionStore } from "@/entities/session/store"
|
||||||
|
|
||||||
type UserRole = "User" | "Candidate" | "Company"
|
// type UserRole = "User" | "Candidate" | "Company"
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
role: z.enum(["User", "Candidate", "Company"]),
|
role: z.enum(["User", "Candidate", "Company"]),
|
||||||
@@ -84,6 +84,7 @@ export function RegisterForm() {
|
|||||||
// Формируем E.164 формат
|
// Формируем E.164 формат
|
||||||
const formattedPhone = cleanPhone.startsWith('7') ? `+${cleanPhone}` : `+7${cleanPhone}`;
|
const formattedPhone = cleanPhone.startsWith('7') ? `+${cleanPhone}` : `+7${cleanPhone}`;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const submitData: any = {
|
const submitData: any = {
|
||||||
phone: formattedPhone,
|
phone: formattedPhone,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { updateGeoStatus, PerformerState } from "@/shared/api/geo";
|
import { updateGeoStatus, PerformerState } from "@/shared/api/geo";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__geoInterval?: ReturnType<typeof setInterval> | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface GeoState {
|
interface GeoState {
|
||||||
/** Включён ли тумблер «На линии» */
|
/** Включён ли тумблер «На линии» */
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
@@ -17,16 +23,30 @@ interface GeoState {
|
|||||||
goOnline: (performerId: string) => void;
|
goOnline: (performerId: string) => void;
|
||||||
/** Выключить тумблер — отправить Offline и остановить отслеживание */
|
/** Выключить тумблер — отправить Offline и остановить отслеживание */
|
||||||
goOffline: (performerId: string) => void;
|
goOffline: (performerId: string) => void;
|
||||||
|
/** Принудительно установить состояние (для синхронизации с БД при загрузке) */
|
||||||
|
setOnline: (performerId: string, online: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGeoStore = create<GeoState>((set, get) => ({
|
export const useGeoStore = create<GeoState>((set, get) => ({
|
||||||
isOnline: false,
|
isOnline: typeof window !== "undefined" ? localStorage.getItem("performer_is_online") === "true" : false,
|
||||||
watchId: null,
|
watchId: null,
|
||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastCoords: null,
|
lastCoords: null,
|
||||||
|
|
||||||
|
setOnline: (performerId: string, online: boolean) => {
|
||||||
|
const { isOnline, goOnline, goOffline } = get();
|
||||||
|
if (online && !isOnline) {
|
||||||
|
goOnline(performerId);
|
||||||
|
} else if (!online && isOnline) {
|
||||||
|
goOffline(performerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
goOnline: (performerId: string) => {
|
goOnline: (performerId: string) => {
|
||||||
|
const { watchId } = get();
|
||||||
|
if (watchId !== null) return; // Уже запущено
|
||||||
|
|
||||||
if (typeof window === "undefined" || !navigator.geolocation) {
|
if (typeof window === "undefined" || !navigator.geolocation) {
|
||||||
set({ error: "Геолокация не поддерживается вашим браузером." });
|
set({ error: "Геолокация не поддерживается вашим браузером." });
|
||||||
return;
|
return;
|
||||||
@@ -35,7 +55,7 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
|||||||
// Таймер для периодической отправки (каждые 10 секунд)
|
// Таймер для периодической отправки (каждые 10 секунд)
|
||||||
let sendInterval: ReturnType<typeof setInterval> | null = null;
|
let sendInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const watchId = navigator.geolocation.watchPosition(
|
const newWatchId = navigator.geolocation.watchPosition(
|
||||||
async (position) => {
|
async (position) => {
|
||||||
const { latitude, longitude } = position.coords;
|
const { latitude, longitude } = position.coords;
|
||||||
set({ lastCoords: { lat: latitude, lon: longitude }, error: null });
|
set({ lastCoords: { lat: latitude, lon: longitude }, error: null });
|
||||||
@@ -89,10 +109,13 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
// Сохраняем watchId и intervalId в замыкании для очистки
|
// Сохраняем watchId и intervalId в замыкании для очистки
|
||||||
set({ isOnline: true, watchId, error: null });
|
set({ isOnline: true, watchId: newWatchId, error: null });
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("performer_is_online", "true");
|
||||||
|
}
|
||||||
|
|
||||||
// Храним intervalId в window для очистки при goOffline
|
// Храним intervalId в window для очистки при goOffline
|
||||||
(window as any).__geoInterval = sendInterval;
|
window.__geoInterval = sendInterval;
|
||||||
},
|
},
|
||||||
|
|
||||||
goOffline: async (performerId: string) => {
|
goOffline: async (performerId: string) => {
|
||||||
@@ -104,9 +127,9 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Останавливаем интервал
|
// Останавливаем интервал
|
||||||
if ((window as any).__geoInterval) {
|
if (window.__geoInterval) {
|
||||||
clearInterval((window as any).__geoInterval);
|
clearInterval(window.__geoInterval);
|
||||||
(window as any).__geoInterval = null;
|
window.__geoInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправляем статус Offline
|
// Отправляем статус Offline
|
||||||
@@ -118,11 +141,14 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
|||||||
longitude: lastCoords.lon,
|
longitude: lastCoords.lon,
|
||||||
state: PerformerState.Offline,
|
state: PerformerState.Offline,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error("[Geo] Ошибка отправки Offline:", err);
|
console.error("[Geo] Ошибка отправки Offline:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ isOnline: false, watchId: null, lastCoords: null, error: null });
|
set({ isOnline: false, watchId: null, lastCoords: null, error: null });
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("performer_is_online", "false");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
// Basic protection logic placeholder
|
||||||
|
const token = request.cookies.get("accessToken")?.value;
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Admin routes protection
|
||||||
|
if (pathname.startsWith("/admin")) {
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL("/auth/login", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Validate role for Admin from token payload
|
||||||
|
// In this MVP, we will only check if the token exists.
|
||||||
|
// Once Admin role is officially introduced, we add:
|
||||||
|
// const decoded = jwtDecode(token);
|
||||||
|
// if (!decoded.roles.includes("Admin")) return redirect("/403");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated user routes protection
|
||||||
|
if (pathname.startsWith("/dashboard")) {
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL("/auth/login", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guest routes protection (e.g., login, register shouldn't be accessed by logged in users)
|
||||||
|
if (pathname.startsWith("/auth/login") || pathname.startsWith("/auth/register")) {
|
||||||
|
if (token) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/admin/:path*",
|
||||||
|
"/dashboard/:path*",
|
||||||
|
"/auth/login",
|
||||||
|
"/auth/register",
|
||||||
|
],
|
||||||
|
};
|
||||||
+34
-4
@@ -18,10 +18,15 @@ api.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
let isRefreshing = false;
|
interface QueueItem {
|
||||||
let failedQueue: any[] = [];
|
resolve: (token: string | null) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const processQueue = (error: any, token: string | null = null) => {
|
let isRefreshing = false;
|
||||||
|
let failedQueue: QueueItem[] = [];
|
||||||
|
|
||||||
|
const processQueue = (error: unknown, token: string | null = null) => {
|
||||||
failedQueue.forEach((prom) => {
|
failedQueue.forEach((prom) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
prom.reject(error);
|
prom.reject(error);
|
||||||
@@ -32,12 +37,14 @@ const processQueue = (error: any, token: string | null = null) => {
|
|||||||
failedQueue = [];
|
failedQueue = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Response interceptor for token refresh
|
// Response interceptor for token refresh and global error handling
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const originalRequest: any = error.config;
|
const originalRequest: any = error.config;
|
||||||
|
|
||||||
|
// 1. Handle 401 Unauthorized (Token Refresh)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -83,6 +90,29 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Global Error Handling (ProblemDetails RFC 7807)
|
||||||
|
if (error.response) {
|
||||||
|
const data = error.response.data as any;
|
||||||
|
const status = error.response.status;
|
||||||
|
|
||||||
|
// Ошибки сервера или бизнес-правил (409/500/etc)
|
||||||
|
if (status >= 500) {
|
||||||
|
const message = data?.detail || "Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.";
|
||||||
|
import("sonner").then(({ toast }) => toast.error(message));
|
||||||
|
}
|
||||||
|
else if (status === 409 || status === 422) {
|
||||||
|
// Бизнес-исключения
|
||||||
|
const message = data?.detail || "Ошибка выполнения операции.";
|
||||||
|
import("sonner").then(({ toast }) => toast.error(message));
|
||||||
|
}
|
||||||
|
else if (status === 403) {
|
||||||
|
import("sonner").then(({ toast }) => toast.error("У вас недостаточно прав для этого действия."));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ошибки сети
|
||||||
|
import("sonner").then(({ toast }) => toast.error("Ошибка сети. Проверьте подключение к интернету."));
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ export interface Category {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
attributeSchema?: any;
|
attributeSchema?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Offer {
|
export interface Offer {
|
||||||
id: string;
|
id: string;
|
||||||
performerId: string;
|
performerId: string;
|
||||||
|
performerName?: string;
|
||||||
|
performerRole?: string;
|
||||||
|
rating?: number;
|
||||||
|
reviewsCount?: number;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -19,7 +23,7 @@ export interface Offer {
|
|||||||
currency: string;
|
currency: string;
|
||||||
type: number; // 0 = Fixed, 1 = Hourly, 2 = Negotiable
|
type: number; // 0 = Fixed, 1 = Hourly, 2 = Negotiable
|
||||||
};
|
};
|
||||||
attributes: Record<string, any> | null;
|
attributes: Record<string, unknown> | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
images: string[];
|
images: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { api } from './axios';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export interface PublicProfile {
|
||||||
|
id: string;
|
||||||
|
fullName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
primaryRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение публичной информации о пользователе
|
||||||
|
*/
|
||||||
|
export const getPublicProfile = async (userId: string): Promise<PublicProfile> => {
|
||||||
|
const response = await api.get<PublicProfile>(`/profile/public/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения публичного профиля
|
||||||
|
*/
|
||||||
|
export const usePublicProfile = (userId: string | undefined) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['publicProfile', userId],
|
||||||
|
queryFn: () => getPublicProfile(userId!),
|
||||||
|
enabled: !!userId,
|
||||||
|
staleTime: 10 * 60 * 1000, // 10 минут
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,10 +9,36 @@ export type OrderStatus =
|
|||||||
| "InProgress"
|
| "InProgress"
|
||||||
| "Completed"
|
| "Completed"
|
||||||
| "Cancelled"
|
| "Cancelled"
|
||||||
| "Expired";
|
| "Expired"
|
||||||
|
| "VerificationPending"
|
||||||
|
| "Disputed";
|
||||||
|
|
||||||
export type OrderType = "Direct" | "PublicJob";
|
export type OrderType = "Direct" | "PublicJob";
|
||||||
|
|
||||||
|
export type DisputeStatus =
|
||||||
|
| "WaitingForPerformer"
|
||||||
|
| "WaitingForCustomer"
|
||||||
|
| "Resolved"
|
||||||
|
| "InArbitration";
|
||||||
|
|
||||||
|
export interface DisputeMessage {
|
||||||
|
id: string;
|
||||||
|
authorId: string;
|
||||||
|
text: string;
|
||||||
|
proposedSolution: string | null;
|
||||||
|
evidence: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dispute {
|
||||||
|
reason: string;
|
||||||
|
status: DisputeStatus;
|
||||||
|
messages: DisputeMessage[];
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
isResolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: string;
|
id: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -32,6 +58,7 @@ export interface Order {
|
|||||||
cancellationReason: string | null;
|
cancellationReason: string | null;
|
||||||
/** Секунды до истечения SLA (только для PendingAcceptance) */
|
/** Секунды до истечения SLA (только для PendingAcceptance) */
|
||||||
slaSecondsLeft: number | null;
|
slaSecondsLeft: number | null;
|
||||||
|
dispute: Dispute | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderPayload {
|
export interface CreateOrderPayload {
|
||||||
@@ -88,3 +115,58 @@ export const cancelOrder = async (
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await api.post(`/orders/${orderId}/cancel`, { requesterId, reason });
|
await api.post(`/orders/${orderId}/cancel`, { requesterId, reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Пометить выполненным (мастер) */
|
||||||
|
export const finishByPerformer = async (orderId: string, performerId: string): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/finish`, { performerId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Подтвердить выполнение (заказчик) */
|
||||||
|
export const confirmOrderCompletion = async (orderId: string, customerId: string): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/confirm`, { customerId });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Открыть спор (заказчик) */
|
||||||
|
export const disputeOrder = async (
|
||||||
|
orderId: string,
|
||||||
|
payload: {
|
||||||
|
customerId: string;
|
||||||
|
reason: string;
|
||||||
|
description: string;
|
||||||
|
proposedSolution: string;
|
||||||
|
evidence: string[];
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/dispute`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ответить на спор (мастер) */
|
||||||
|
export const respondToDispute = async (
|
||||||
|
orderId: string,
|
||||||
|
payload: {
|
||||||
|
performerId: string;
|
||||||
|
position: string;
|
||||||
|
evidence: string[];
|
||||||
|
counterSolution?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/dispute/respond`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Возразить на ответ исполнителя (заказчик) */
|
||||||
|
export const rebutDispute = async (
|
||||||
|
orderId: string,
|
||||||
|
payload: {
|
||||||
|
customerId: string;
|
||||||
|
comment: string;
|
||||||
|
evidence: string[];
|
||||||
|
counterSolution?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/dispute/rebut`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Принять условия спора (заказчик) */
|
||||||
|
export const acceptDisputeTerms = async (orderId: string, customerId: string): Promise<void> => {
|
||||||
|
await api.post(`/orders/${orderId}/dispute/accept`, { customerId });
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { api } from './axios';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO для рейтинга профиля
|
||||||
|
*/
|
||||||
|
export interface ProfileRating {
|
||||||
|
averageRating: number;
|
||||||
|
totalReviews: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO для дополнения к отзыву
|
||||||
|
*/
|
||||||
|
export interface ReviewAdditionDto {
|
||||||
|
comment: string;
|
||||||
|
mediaUrls: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO для отзыва
|
||||||
|
*/
|
||||||
|
export interface ReviewDto {
|
||||||
|
id: string;
|
||||||
|
orderId: string;
|
||||||
|
authorId: string;
|
||||||
|
targetId: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
isAutoGenerated: boolean;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
additions?: ReviewAdditionDto[];
|
||||||
|
author?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос на создание отзыва
|
||||||
|
*/
|
||||||
|
export interface LeaveReviewRequest {
|
||||||
|
orderId: string;
|
||||||
|
offerId: string;
|
||||||
|
authorId: string;
|
||||||
|
targetId: string;
|
||||||
|
rating: number;
|
||||||
|
comment?: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос на дополнение отзыва
|
||||||
|
*/
|
||||||
|
export interface AddReviewAdditionRequest {
|
||||||
|
reviewId: string;
|
||||||
|
authorId: string;
|
||||||
|
comment: string;
|
||||||
|
mediaUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение рейтинга профиля пользователя
|
||||||
|
*/
|
||||||
|
export const getProfileRating = async (userId: string): Promise<ProfileRating> => {
|
||||||
|
const response = await api.get<ProfileRating>(`/reputation/${userId}/rating`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка отзывов о пользователе с пагинацией
|
||||||
|
*/
|
||||||
|
export const getProfileReviews = async (userId: string, page: number = 1, pageSize: number = 10): Promise<{ reviews: ReviewDto[]; totalCount: number }> => {
|
||||||
|
const response = await api.get(`/reputation/${userId}/reviews`, {
|
||||||
|
params: { page, pageSize }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание отзыва
|
||||||
|
*/
|
||||||
|
export const leaveReview = async (request: LeaveReviewRequest): Promise<{ reviewId: string }> => {
|
||||||
|
const response = await api.post<{ reviewId: string }>('/reputation/reviews', request);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение рейтинга конкретной услуги
|
||||||
|
*/
|
||||||
|
export const getOfferRating = async (offerId: string): Promise<ProfileRating> => {
|
||||||
|
const response = await api.get<ProfileRating>(`/reputation/offer/${offerId}/rating`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения рейтинга профиля
|
||||||
|
*/
|
||||||
|
export const useProfileRating = (userId: string | undefined) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profileRating', userId],
|
||||||
|
queryFn: () => getProfileRating(userId!),
|
||||||
|
enabled: !!userId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 минут
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения рейтинга услуги
|
||||||
|
*/
|
||||||
|
export const useOfferRating = (offerId: string | undefined) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['offerRating', offerId],
|
||||||
|
queryFn: () => getOfferRating(offerId!),
|
||||||
|
enabled: !!offerId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 минут
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения отзывов профиля
|
||||||
|
*/
|
||||||
|
export const useProfileReviews = (userId: string | undefined, page: number = 1, pageSize: number = 10) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['profileReviews', userId, page, pageSize],
|
||||||
|
queryFn: () => getProfileReviews(userId!, page, pageSize),
|
||||||
|
enabled: !!userId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для создания отзыва
|
||||||
|
*/
|
||||||
|
export const useLeaveReview = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: leaveReview,
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
// Инвалидируем кэш рейтинга и отзывов после создания отзыва
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profileRating', variables.targetId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profileReviews', variables.targetId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для дополнения отзыва
|
||||||
|
*/
|
||||||
|
export const useAddReviewAddition = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (request: AddReviewAdditionRequest) => {
|
||||||
|
const { reviewId, ...body } = request;
|
||||||
|
const response = await api.post(`/reputation/reviews/${reviewId}/additions`, body);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profileReviews'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,10 +4,11 @@ export interface SearchOffer {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
priceAmount?: number
|
priceAmount?: number;
|
||||||
priceCurrency?: string
|
priceCurrency?: string;
|
||||||
amount?: number
|
imageUrl?: string;
|
||||||
currency?: string
|
rating?: number;
|
||||||
|
reviewsCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResultItem {
|
export interface SearchResultItem {
|
||||||
@@ -19,6 +20,8 @@ export interface SearchResultItem {
|
|||||||
status: "Готов к заказу" | "Офлайн"
|
status: "Готов к заказу" | "Офлайн"
|
||||||
distance?: number
|
distance?: number
|
||||||
role?: string
|
role?: string
|
||||||
|
rating?: number
|
||||||
|
reviewsCount?: number
|
||||||
matchedCompetencies: string[]
|
matchedCompetencies: string[]
|
||||||
matchedOffers: SearchOffer[]
|
matchedOffers: SearchOffer[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { UseFormSetError, FieldValues, Path } from "react-hook-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибка API в формате ProblemDetails (RFC 7807)
|
||||||
|
*/
|
||||||
|
export interface ProblemDetails {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
detail?: string;
|
||||||
|
instance?: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг ошибок валидации с бэкенда на поля React Hook Form.
|
||||||
|
* Ожидает структуру errors: { "FieldName": ["Error message"] }
|
||||||
|
*/
|
||||||
|
export function mapApiErrorsToForm<T extends FieldValues>(
|
||||||
|
errorData: ProblemDetails,
|
||||||
|
setError: UseFormSetError<T>
|
||||||
|
) {
|
||||||
|
if (errorData.errors) {
|
||||||
|
Object.entries(errorData.errors).forEach(([field, messages]) => {
|
||||||
|
// Приводим первую букву в нижний регистр, если бэк шлет PascalCase (например Title -> title)
|
||||||
|
const formField = (field.charAt(0).toLowerCase() + field.slice(1)) as Path<T>;
|
||||||
|
|
||||||
|
setError(formField, {
|
||||||
|
type: "manual",
|
||||||
|
message: messages[0], // Берем первую ошибку из списка
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const getRatingColor = (rating: number) => {
|
||||||
|
if (rating >= 4.0) return "text-green-600 dark:text-green-500 fill-green-600 dark:fill-green-500";
|
||||||
|
if (rating >= 3.0) return "text-blue-600 dark:text-blue-500 fill-blue-600 dark:fill-blue-500";
|
||||||
|
return "text-rose-600 dark:text-rose-500 fill-rose-600 dark:fill-rose-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRatingBadgeClasses = (rating: number) => {
|
||||||
|
if (rating >= 4.0) return "bg-green-500/10 text-green-600 border-green-500/10";
|
||||||
|
if (rating >= 3.0) return "bg-blue-500/10 text-blue-600 border-blue-500/10";
|
||||||
|
return "bg-rose-500/10 text-rose-600 border-rose-500/10";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRatingStarColor = (rating: number) => {
|
||||||
|
if (rating >= 4.0) return "fill-green-600 dark:fill-green-500 text-green-600 dark:text-green-500";
|
||||||
|
if (rating >= 3.0) return "fill-blue-600 dark:fill-blue-500 text-blue-600 dark:text-blue-500";
|
||||||
|
return "fill-rose-600 dark:fill-rose-500 text-rose-600 dark:text-rose-500";
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const Badge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "secondary" | "destructive" | "outline" }
|
||||||
|
>(({ className, variant = "default", ...props }, ref) => {
|
||||||
|
const variants = {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
variants[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Badge.displayName = "Badge"
|
||||||
|
|
||||||
|
export { Badge }
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, children }: DialogProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
// Передаем onOpenChange через контекст или рендерим children с пропсами
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogContent({ children, className, onClose, ...props }: DialogContentProps) {
|
||||||
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
// Блокируем скролл основной страницы при открытой модалке
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-md animate-in fade-in duration-300"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-[101] w-full max-w-lg mx-4 bg-white dark:bg-zinc-900 rounded-[32px] shadow-2xl animate-in zoom-in-95 duration-300",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Кнопка закрытия (X) */}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
className="absolute right-6 top-6 rounded-full p-2 bg-zinc-100 dark:bg-zinc-800 opacity-70 transition-all hover:opacity-100 hover:scale-110 z-[102]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Закрыть</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> { }
|
||||||
|
|
||||||
|
export function DialogHeader({ className, ...props }: DialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> { }
|
||||||
|
|
||||||
|
export function DialogFooter({ className, ...props }: DialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> { }
|
||||||
|
|
||||||
|
export function DialogTitle({ className, ...props }: DialogTitleProps) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> { }
|
||||||
|
|
||||||
|
export function DialogDescription({ className, ...props }: DialogDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogClose({ className, asChild, ...props }: DialogCloseProps) {
|
||||||
|
// Если asChild=true, нужно использовать Slot из radix-ui
|
||||||
|
if (asChild) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
|
||||||
|
const { Slot } = require("@radix-ui/react-slot") as { Slot: React.ComponentType<any> }
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
className={cn(
|
||||||
|
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Закрыть</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-[1000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-[1000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/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, formState } = useFormContext()
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Label>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
style={{ zIndex: 9999 }}
|
||||||
|
className={cn(
|
||||||
|
"relative max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||||
|
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)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toaster]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toaster]:bg-primary group-[.toaster]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toaster]:bg-muted group-[.toaster]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
interface SwitchProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
checked?: boolean
|
||||||
|
onCheckedChange?: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключатель (тумблер) — аналог Shadcn Switch.
|
||||||
|
* Используется для тумблера «На линии» в шапке.
|
||||||
|
*/
|
||||||
|
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||||
|
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => onCheckedChange?.(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
checked ? "bg-green-500" : "bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Switch.displayName = "Switch"
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
+23
-1
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import * as React from "react"
|
||||||
import { ModeToggle } from "@/components/theme/mode-toggle"
|
import { ModeToggle } from "@/components/theme/mode-toggle"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
@@ -19,7 +20,28 @@ import { useGeoStore } from "@/features/geo/useGeoStore"
|
|||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { isAuth, user, logout } = useSessionStore()
|
const { isAuth, user, logout } = useSessionStore()
|
||||||
const { isOnline, isUpdating, error: geoError, goOnline, goOffline } = useGeoStore()
|
const { isOnline, isUpdating, error: geoError, goOnline, goOffline, setOnline } = useGeoStore()
|
||||||
|
const [isSynced, setIsSynced] = React.useState(false)
|
||||||
|
|
||||||
|
// Синхронизируем статус при загрузке пользователя
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user && user.id) {
|
||||||
|
// 1. Синхронизация с бэкендом (только один раз при загрузке)
|
||||||
|
if (!isSynced) {
|
||||||
|
const backendOnline = user.status === "Available"
|
||||||
|
if (backendOnline !== isOnline) {
|
||||||
|
setOnline(user.id, backendOnline)
|
||||||
|
}
|
||||||
|
setIsSynced(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если мы «в сети» по состоянию стора (напр. из localStorage),
|
||||||
|
// но процессы слежения не запущены (напр. после релоада) — запускаем их.
|
||||||
|
if (isOnline && useGeoStore.getState().watchId === null) {
|
||||||
|
goOnline(user.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user, isOnline, setOnline, isSynced, goOnline])
|
||||||
|
|
||||||
// Проверяем, является ли пользователь исполнителем или компанией
|
// Проверяем, является ли пользователь исполнителем или компанией
|
||||||
const isMasterOrCompany = user?.roles?.includes("Candidate") || user?.roles?.includes("Company") || user?.roles?.includes("Master")
|
const isMasterOrCompany = user?.roles?.includes("Candidate") || user?.roles?.includes("Company") || user?.roles?.includes("Master")
|
||||||
|
|||||||
Reference in New Issue
Block a user