Переписаны модули, тесты, обработка ошибок
This commit is contained in:
@@ -1,166 +1,91 @@
|
||||
# ✨ 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, маршрутизация |
|
||||
| **TypeScript** | Типизация |
|
||||
| **Tailwind CSS** | Стилизация |
|
||||
| **Shadcn/UI** | Готовые UI компоненты |
|
||||
| **Zustand** | Глобальное состояние (геолокация) |
|
||||
| **React Leaflet** | Карта (с отключённым SSR) |
|
||||
| **React Hook Form + Zod** | Формы и валидация |
|
||||
| **Axios** | HTTP-запросы к API |
|
||||
| **Lucide React** | Иконки |
|
||||
| **Sonner** | Тосты/уведомления |
|
||||
| **Next.js 15** | Фреймворк для SSR, маршрутизации и SEO-оптимизации |
|
||||
| **Tailwind CSS** | Адаптивная верстка и дизайн-система |
|
||||
| **Shadcn/UI** | Высококачественные доступные компоненты интерфейса |
|
||||
| **Zustand** | Глобальное состояние (геолокация, сессии) |
|
||||
| **React Leaflet** | Интерактивная карта (с динамической подгрузкой) |
|
||||
| **Axios** | Интеграция с API (авторизация, заказы, репутация) |
|
||||
| **React Hook Form** | Комплексная валидация форм (Zod) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Текущая реализация
|
||||
|
||||
### 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)
|
||||
- [ ] Страница профиля исполнителя (публичная)
|
||||
- [ ] Система отзывов и рейтингов (реальные данные)
|
||||
- [ ] Оформление заказа через платформу
|
||||
- [ ] Push-уведомления
|
||||
- [ ] Мобильное приложение (React Native)
|
||||
- [ ] Полнотекстовый фильтр по категориям и тегам
|
||||
- [ ] Похожие услуги (реальные данные из той же категории)
|
||||
- [ ] SEO-оптимизация публичных страниц
|
||||
- [ ] **Real-time Chat:** Чат между клиентом и мастером на WebSockets.
|
||||
- [ ] **Public Profiles:** Публичные страницы мастеров с портфолио и отзывами.
|
||||
- [ ] **SEO Boost:** Гидратация мета-тегов для индексации поисковиками.
|
||||
- [ ] **Push Notifications:** Уведомления о новых заказах и сообщениях.
|
||||
- [ ] **Mobile App:** Разработка нативного приложения на React Native.
|
||||
- [ ] **Verification UI:** Интерфейс загрузки документов для подтверждения статуса.
|
||||
|
||||
@@ -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": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@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-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
@@ -34,7 +36,7 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"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-leaflet": "^5.0.0",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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": {
|
||||
"version": "7.71.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||
"version": "7.71.2",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
+4
-2
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@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-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
@@ -35,7 +37,7 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"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-leaflet": "^5.0.0",
|
||||
"sonner": "^1.7.1",
|
||||
@@ -55,5 +57,5 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"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 { Input } from '@/components/ui/input'
|
||||
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 { useSessionStore } from '@/entities/session/store'
|
||||
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 - только лучшее совпадение)
|
||||
React.useEffect(() => {
|
||||
const searchAddress = async () => {
|
||||
@@ -194,6 +189,7 @@ export default function BecomePerformerPage() {
|
||||
}
|
||||
)
|
||||
const data = await response.json()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const addresses = data.map((item: any) => {
|
||||
const city = item.address.city || item.address.town || item.address.village || ''
|
||||
const street = item.address.road || ''
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
User,
|
||||
Star,
|
||||
MapPin,
|
||||
ArrowRight,
|
||||
CreditCard,
|
||||
Gavel,
|
||||
} from "lucide-react"
|
||||
import { useSessionStore } from "@/entities/session/store"
|
||||
import { formatPrice } from "@/shared/lib/formatPrice"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Order,
|
||||
OrderStatus,
|
||||
@@ -30,9 +30,18 @@ import {
|
||||
acceptOrder,
|
||||
completeOrder,
|
||||
cancelOrder,
|
||||
finishByPerformer,
|
||||
confirmOrderCompletion,
|
||||
} from "@/shared/api/orders"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -109,6 +118,16 @@ const STATUS_CONFIG: Record<
|
||||
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
|
||||
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 }) {
|
||||
@@ -131,10 +150,21 @@ function StatusBadge({ status }: { status: OrderStatus }) {
|
||||
interface OrderCardProps {
|
||||
order: Order
|
||||
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 router = useRouter()
|
||||
|
||||
@@ -142,40 +172,67 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
||||
qc.invalidateQueries({ queryKey: ["orders"] })
|
||||
}
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
const mutateAccept = useMutation({
|
||||
mutationFn: () => acceptOrder(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 completeMutation = useMutation({
|
||||
const mutateComplete = useMutation({
|
||||
mutationFn: () => completeOrder(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 cancelMutation = useMutation({
|
||||
const mutateCancel = useMutation({
|
||||
mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
|
||||
onSuccess: () => {
|
||||
toast.success("Заказ отменён")
|
||||
invalidate()
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onError: (err: any) =>
|
||||
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 =
|
||||
acceptMutation.isPending ||
|
||||
completeMutation.isPending ||
|
||||
cancelMutation.isPending
|
||||
mutateAccept.isPending ||
|
||||
mutateComplete.isPending ||
|
||||
mutateCancel.isPending ||
|
||||
mutateFinishByPerformer.isPending ||
|
||||
mutateConfirmCompletion.isPending
|
||||
|
||||
const isCustomer = order.customerId === userId
|
||||
|
||||
@@ -262,6 +319,27 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
||||
</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 && (
|
||||
<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
|
||||
className="rounded-2xl h-12 shadow-lg shadow-primary/20 bg-primary hover:bg-primary/90 font-bold text-sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
onClick={() => mutateAccept.mutate()}
|
||||
>
|
||||
{acceptMutation.isPending ? (
|
||||
{mutateAccept.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Принять заказ"
|
||||
@@ -293,9 +371,9 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
||||
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"
|
||||
disabled={isLoading}
|
||||
onClick={() => cancelMutation.mutate("Отклонён мастером")}
|
||||
onClick={() => mutateCancel.mutate("Отклонён мастером")}
|
||||
>
|
||||
{cancelMutation.isPending ? (
|
||||
{mutateCancel.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Отклонить"
|
||||
@@ -304,14 +382,14 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Завершить работу (мастер или клиент) */}
|
||||
{order.status === "InProgress" && (
|
||||
{/* Завершить работу (мастер) */}
|
||||
{!isCustomer && order.status === "InProgress" && (
|
||||
<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"
|
||||
disabled={isLoading}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
onClick={() => mutateFinishByPerformer.mutate()}
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
{mutateFinishByPerformer.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Завершить работу"
|
||||
@@ -319,21 +397,73 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
||||
</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" && (
|
||||
<Button
|
||||
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"
|
||||
disabled={isLoading}
|
||||
onClick={() => cancelMutation.mutate("Отменён заказчиком")}
|
||||
onClick={() => mutateCancel.mutate("Отменён заказчиком")}
|
||||
>
|
||||
{cancelMutation.isPending ? (
|
||||
{mutateCancel.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Отменить заказ"
|
||||
)}
|
||||
</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>
|
||||
@@ -343,10 +473,10 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
|
||||
// ─── Главная страница ─────────────────────────────────────────────────────────
|
||||
|
||||
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"]
|
||||
|
||||
type OrderTab = "new" | "process" | "done"
|
||||
type OrderTab = "new" | "process" | "done" | "reviews"
|
||||
|
||||
export default function OrdersDashboardPage() {
|
||||
const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
|
||||
@@ -355,6 +485,86 @@ export default function OrdersDashboardPage() {
|
||||
const [customerTab, setCustomerTab] = 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(() => {
|
||||
if (isInitialized && !isAuthLoading && !isAuth) {
|
||||
@@ -424,11 +634,14 @@ export default function OrdersDashboardPage() {
|
||||
allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const allOrders = Array.from(allOrdersMap.values())
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const isLoading =
|
||||
(customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
|
||||
const isFetching = customerQuery.isFetching || performerQuery.isFetching
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const error = customerQuery.error || performerQuery.error
|
||||
|
||||
const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
|
||||
@@ -450,6 +663,9 @@ export default function OrdersDashboardPage() {
|
||||
const customerCounts = getCounts(customerQuery.data)
|
||||
const performerCounts = getCounts(performerQuery.data)
|
||||
|
||||
const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(user?.id)
|
||||
const { data: ratingData } = useProfileRating(user?.id)
|
||||
|
||||
// Ждём инициализации сессии
|
||||
if (!isInitialized || isAuthLoading) {
|
||||
return (
|
||||
@@ -493,7 +709,7 @@ export default function OrdersDashboardPage() {
|
||||
</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">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -556,7 +772,7 @@ export default function OrdersDashboardPage() {
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
) : (
|
||||
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 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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
+6
-3
@@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -24,10 +23,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { 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 { ImageUploader } from "@/components/ui/image-uploader";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -111,6 +110,8 @@ export default function EditOfferPage() {
|
||||
images: offerData.images || [],
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
toast.error("Не удалось загрузить данные услуги");
|
||||
router.push("/dashboard/offers");
|
||||
} finally {
|
||||
@@ -145,6 +146,8 @@ export default function EditOfferPage() {
|
||||
toast.success("Услуга успешно обновлена");
|
||||
router.push("/dashboard/offers");
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
toast.error("Не удалось обновить услугу. Проверьте данные и попробуйте снова.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
+6
-2
@@ -24,7 +24,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { getCategories, createOffer, Category } from "@/shared/api/catalog";
|
||||
@@ -93,6 +93,8 @@ export default function CreateOfferPage() {
|
||||
const data = await getCategories();
|
||||
setCategories(data || []);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
toast.error("Ошибка загрузки категорий");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -126,6 +128,8 @@ export default function CreateOfferPage() {
|
||||
toast.success("Услуга успешно создана");
|
||||
router.push("/dashboard/offers");
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
toast.error("Не удалось создать услугу. Проверьте данные и попробуйте снова.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -286,7 +290,7 @@ export default function CreateOfferPage() {
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
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 { formatPrice } from "@/shared/lib/formatPrice";
|
||||
|
||||
@@ -32,8 +32,7 @@ export default function MyOffersPage() {
|
||||
try {
|
||||
const data = await getMyOffers();
|
||||
setOffers(data || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch {
|
||||
toast.error("Не удалось загрузить ваши услуги");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/entities/session/store"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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 { useForm } from "react-hook-form"
|
||||
@@ -19,7 +19,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { formatPhoneNumber } from "@/shared/lib/utils"
|
||||
import { AvatarUploader } from "@/components/ui/avatar-uploader"
|
||||
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 { ScheduleViewer } from "@/components/ui/schedule-viewer"
|
||||
import { ChangePasswordModal } from "@/components/ui/change-password-modal"
|
||||
@@ -67,7 +68,7 @@ export default function ProfilePage() {
|
||||
const [isLocationModalOpen, setIsLocationModalOpen] = React.useState(false)
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false)
|
||||
|
||||
const rating = useRandomRating()
|
||||
const { data: ratingData, isLoading: ratingLoading } = useProfileRating(user?.id)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isInitialized) return
|
||||
@@ -77,7 +78,7 @@ export default function ProfilePage() {
|
||||
} else if (isAuth && !user) {
|
||||
getProfile()
|
||||
}
|
||||
}, [isAuth, isLoading, user, router, isInitialized])
|
||||
}, [isAuth, isLoading, user, router, isInitialized, getProfile])
|
||||
|
||||
const profileForm = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
@@ -110,6 +111,8 @@ export default function ProfilePage() {
|
||||
setIsEditingProfile(false)
|
||||
toast.success("Профиль успешно обновлен")
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error)
|
||||
toast.error("Ошибка при обновлении профиля")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +123,8 @@ export default function ProfilePage() {
|
||||
setIsChangingPhone(false)
|
||||
toast.success("Номер телефона успешно изменен")
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error)
|
||||
toast.error("Ошибка при смене номера телефона")
|
||||
}
|
||||
}
|
||||
@@ -187,6 +192,7 @@ export default function ProfilePage() {
|
||||
(isMaster || isCompany) && user.location && { id: "location", label: "Локация", icon: MapPin },
|
||||
(isMaster || isCompany) && { id: "schedule", label: "График работы", icon: Clock },
|
||||
{ id: "account", label: "Управление аккаунтом", icon: ShieldCheck },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
].filter(Boolean) as { id: string, label: string, icon: any }[]
|
||||
|
||||
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">
|
||||
{translateRole(getHighestRole(user.roles))}
|
||||
</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>
|
||||
@@ -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 { getOfferById, Offer } from "@/shared/api/catalog"
|
||||
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 { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
||||
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() {
|
||||
const params = useParams()
|
||||
@@ -30,7 +35,9 @@ export default function PublicOfferPage() {
|
||||
if (data.images && data.images.length > 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("Не удалось загрузить услугу")
|
||||
console.error("Fetch offer error:", error)
|
||||
} finally {
|
||||
@@ -41,6 +48,11 @@ export default function PublicOfferPage() {
|
||||
fetchOffer()
|
||||
}, [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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!offer || !offer.images || offer.images.length <= 1) return;
|
||||
@@ -206,13 +218,50 @@ export default function PublicOfferPage() {
|
||||
{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">
|
||||
<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 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>
|
||||
|
||||
{/* Правая колонка - Карточка заказа / Информация */}
|
||||
@@ -221,39 +270,38 @@ export default function PublicOfferPage() {
|
||||
<div className="bg-card border rounded-xl p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold mb-4">Об исполнителе</h3>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg">Имя Фамилия</h4>
|
||||
<p className="text-sm font-medium text-muted-foreground bg-muted inline-block px-2 py-0.5 rounded mt-1">Мастер</p>
|
||||
<div className="overflow-hidden">
|
||||
<h4 className="font-bold text-lg truncate flex items-center gap-2">
|
||||
<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 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">
|
||||
<span className="text-2xl font-black flex items-center gap-1">
|
||||
5.0 <Star className="w-5 h-5 fill-yellow-400 text-yellow-400" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground underline underline-offset-2 cursor-pointer hover:text-foreground transition-colors">
|
||||
42 отзыва
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-3xl font-black text-foreground">{ratingData?.averageRating.toFixed(1) || '0.0'}</span>
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">
|
||||
/ 5.0
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px bg-border my-1" />
|
||||
<div className="flex flex-col justify-center">
|
||||
<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>
|
||||
<Rating value={ratingData?.averageRating || 0} size="md" showValue={false} showCount count={ratingData?.totalReviews} />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import * as React from "react"
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react"
|
||||
import { Search, User, Filter, MapPin, Briefcase } from "lucide-react"
|
||||
import { SearchResultItem, SearchParams, globalSearch } from "@/shared/api/search"
|
||||
import {
|
||||
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 { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
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"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
@@ -31,18 +41,15 @@ export default function SearchPage() {
|
||||
const [centerTrigger, setCenterTrigger] = useState(0)
|
||||
const [sortBy, setSortBy] = useState<string>("default")
|
||||
|
||||
// Параметры поиска
|
||||
const [query, setQuery] = useState("")
|
||||
const [inTitle, setInTitle] = useState(true)
|
||||
const [inDesc, setInDesc] = useState(true)
|
||||
const [inComp, setInComp] = useState(true)
|
||||
|
||||
// Текущая позиция карты (и инициализация из localStorage)
|
||||
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 [isLocationLoaded, setIsLocationLoaded] = useState(false)
|
||||
const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173])
|
||||
const [, setIsLocationLoaded] = useState(false)
|
||||
|
||||
// Пытаемся восстановить локацию при монтировании
|
||||
useEffect(() => {
|
||||
try {
|
||||
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) => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||
|
||||
@@ -96,7 +102,7 @@ export default function SearchPage() {
|
||||
const currentLon = lon ?? mapBounds?.lon
|
||||
const currentRad = rad ?? mapBounds?.radius
|
||||
|
||||
if (!currentLat || !currentLon) return // Ждём, пока карта даст координаты
|
||||
if (!currentLat || !currentLon) return
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
performSearch(
|
||||
@@ -123,18 +129,15 @@ export default function SearchPage() {
|
||||
|
||||
const handlePerformerClick = useCallback((id: string) => {
|
||||
setActivePerformerId(id)
|
||||
const scrollId = `performer-card-${id}`
|
||||
if (sidebarOpen) {
|
||||
const el = document.getElementById(`performer-card-${id}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
const el = document.getElementById(scrollId)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
} else {
|
||||
setSidebarOpen(true)
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`performer-card-${id}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
const el = document.getElementById(scrollId)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 300)
|
||||
}
|
||||
}, [sidebarOpen])
|
||||
@@ -145,11 +148,12 @@ export default function SearchPage() {
|
||||
}, [])
|
||||
|
||||
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 => {
|
||||
if (p.matchedOffers && p.matchedOffers.length > 0) {
|
||||
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 {
|
||||
items.push({ type: 'performer', performer: p, distance: p.distance || 0, price: 0 });
|
||||
@@ -168,14 +172,12 @@ export default function SearchPage() {
|
||||
|
||||
return (
|
||||
<div className="flex w-full" style={{ height: "calc(100vh - 64px)" }}>
|
||||
{/* === Боковая панель: Поиск + Список мастеров === */}
|
||||
{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">
|
||||
{/* Заголовок и фильтры */}
|
||||
<div className="p-4 border-b space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<button
|
||||
@@ -186,9 +188,8 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Строка поиска */}
|
||||
<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
|
||||
placeholder="Что вы ищете? Напр. 'бар', 'ремонт'"
|
||||
className="pl-9 bg-muted/50 focus-visible:ring-primary"
|
||||
@@ -200,7 +201,6 @@ export default function SearchPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Чекбоксы зон поиска */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Искать в:</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
@@ -238,7 +238,6 @@ export default function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список найденных мастеров */}
|
||||
<div className="flex-1 overflow-y-auto bg-muted/10">
|
||||
{query.trim() === "" ? (
|
||||
<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>
|
||||
<p className="text-sm font-medium">Введите запрос для поиска</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Укажите название услуги, например 'уборка' или 'няня'.
|
||||
Укажите название услуги, например 'уборка' или 'няня'.
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading && performers.length === 0 ? (
|
||||
@@ -258,11 +257,11 @@ export default function SearchPage() {
|
||||
) : performers.length === 0 ? (
|
||||
<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">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
<SearchIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Ничего не найдено</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Попробуйте изменить запрос, расширить зону поиска или убрать фильтры.
|
||||
Мы не нашли исполнителей непосредственно в "{query}", но подобрали лучших мастеров поблизости.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -287,7 +286,8 @@ export default function SearchPage() {
|
||||
{sortedItems.map((item, idx) => {
|
||||
const p = item.performer
|
||||
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) => {
|
||||
if (d === undefined || d === null) return 'В вашем районе';
|
||||
@@ -302,11 +302,13 @@ export default function SearchPage() {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
id={`performer-card-${p.performerId}-${idx}`}
|
||||
onClick={() => handleListPerformerClick(p.performerId)}
|
||||
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'}`}
|
||||
id={`performer-card-${cardId}`}
|
||||
onClick={() => handleListPerformerClick(cardId)}
|
||||
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' ? (
|
||||
<div className="mb-4 flex gap-4 items-stretch">
|
||||
{item.offer.imageUrl && (
|
||||
@@ -321,7 +323,33 @@ export default function SearchPage() {
|
||||
)}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<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>
|
||||
<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">
|
||||
<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) : "Цена не указана"}
|
||||
@@ -342,7 +370,7 @@ export default function SearchPage() {
|
||||
href={`/offers/${item.offer.id}`}
|
||||
target="_blank"
|
||||
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"
|
||||
>
|
||||
Просмотреть услугу
|
||||
@@ -353,7 +381,7 @@ export default function SearchPage() {
|
||||
) : (
|
||||
<div className="mb-1">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Найдено по компетенции
|
||||
Найдено {sortedItems.length} предложений' по запросу “{query}”
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.matchedCompetencies.map(c => (
|
||||
@@ -365,7 +393,6 @@ export default function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Карточка исполнителя */}
|
||||
<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">
|
||||
<User className="h-4 w-4" />
|
||||
@@ -380,12 +407,34 @@ export default function SearchPage() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-xs truncate">{p.name}</p>
|
||||
<span className={`flex items-center gap-1 text-[10px] font-medium shrink-0 flex-nowrap ${isOnline ? "text-green-500" : "text-gray-400"}`}>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${isOnline ? "bg-green-500 animate-pulse" : "bg-gray-400"}`} />
|
||||
<p className="font-medium text-xs truncate flex items-center gap-1">
|
||||
<span title="Личность подтверждена">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -406,39 +455,62 @@ export default function SearchPage() {
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Filter className="h-4 w-4 text-primary" />
|
||||
Фильтры и список
|
||||
</button>
|
||||
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",
|
||||
sidebarOpen ? "opacity-0 invisible pointer-events-none" : "opacity-100 visible"
|
||||
)}
|
||||
>
|
||||
<SearchCheck className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* === Карта === */}
|
||||
<div className="flex-1 relative z-0">
|
||||
{isLocationLoaded ? (
|
||||
<main className="flex-1 relative">
|
||||
<LiveMap
|
||||
initialCenter={initialCenter}
|
||||
performers={performers}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
initialCenter={initialCenter}
|
||||
onBoundsChange={handleBoundsChange}
|
||||
onUserLocated={handleUserLocated}
|
||||
activePerformerId={activePerformerId}
|
||||
centerTrigger={centerTrigger}
|
||||
onPerformerClick={handlePerformerClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full bg-muted/30">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-10 w-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Определение вашей позиции...</p>
|
||||
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
||||
<div className="bg-background/90 backdrop-blur border rounded-lg p-2 shadow-sm flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<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 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 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
<AuthProvider>
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
{children}
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</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"
|
||||
|
||||
export function PerformerButton() {
|
||||
const { isAuth, user } = useSessionStore()
|
||||
const { user } = useSessionStore()
|
||||
|
||||
// Проверяем, является ли пользователь исполнителем или компанией
|
||||
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 { useRouter, usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { RatingStats } from "@/components/reputation/RatingStats"
|
||||
|
||||
export function Header() {
|
||||
const { user, isAuth, getProfile } = useSessionStore()
|
||||
@@ -77,6 +78,7 @@ export function Header() {
|
||||
Подобрать заказ
|
||||
</Button>
|
||||
)}
|
||||
<RatingStats userId={user!.id} size={16} className="hidden sm:flex" />
|
||||
<Button variant="ghost" onClick={() => router.push('/dashboard/profile')}>
|
||||
{user?.firstName || 'Профиль'}
|
||||
</Button>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react"
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet"
|
||||
import L from "leaflet"
|
||||
import { ShieldCheck } from "lucide-react"
|
||||
import { SearchResultItem } from "@/shared/api/search"
|
||||
import { Offer } from "@/shared/api/catalog"
|
||||
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
|
||||
import { RatingStats } from "@/components/reputation/RatingStats"
|
||||
|
||||
// Фикс иконок Leaflet для webpack/Next.js
|
||||
const defaultIcon = L.icon({
|
||||
@@ -189,8 +191,8 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
||||
title: o.title,
|
||||
description: o.description ?? "",
|
||||
price: {
|
||||
amount: o.priceAmount ?? o.amount ?? 0,
|
||||
currency: o.priceCurrency ?? o.currency ?? "RUB",
|
||||
amount: o.priceAmount ?? 0,
|
||||
currency: o.priceCurrency ?? "RUB",
|
||||
type: 0,
|
||||
},
|
||||
attributes: null,
|
||||
@@ -259,10 +261,16 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
|
||||
<Popup>
|
||||
<div className="text-sm min-w-[180px] space-y-2">
|
||||
<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.status}
|
||||
</p>
|
||||
<RatingStats userId={p.performerId} size={14} className="mt-1" />
|
||||
</div>
|
||||
|
||||
{p.matchedOffers.length > 0 && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
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 { Offer } from "@/shared/api/catalog"
|
||||
import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice"
|
||||
@@ -26,7 +26,7 @@ interface CreateOrderModalProps {
|
||||
/** Имя мастера (для отображения в заказе) */
|
||||
performerName?: string
|
||||
/** Список услуг этого мастера (для выбора) */
|
||||
offers: (Offer | any)[]
|
||||
offers: Offer[]
|
||||
/** Предустановка конкретной услуги */
|
||||
preselectedOfferId?: string
|
||||
userLocation?: { lat: number; lon: number; address?: string }
|
||||
@@ -63,7 +63,7 @@ export function CreateOrderModal({
|
||||
} else if (offers.length > 0 && !selectedOfferId) {
|
||||
setSelectedOfferId(offers[0].id)
|
||||
}
|
||||
}, [preselectedOfferId, offers])
|
||||
}, [preselectedOfferId, offers, selectedOfferId])
|
||||
|
||||
const selectedOffer = offers.find((o) => o.id === selectedOfferId)
|
||||
|
||||
@@ -95,12 +95,14 @@ export function CreateOrderModal({
|
||||
performerName: performerName,
|
||||
}
|
||||
|
||||
const orderId = await createOrder(payload)
|
||||
await createOrder(payload)
|
||||
toast.success("Заказ успешно оформлен! Мастер получит уведомление.")
|
||||
onOpenChange(false)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const error = err as any
|
||||
const message =
|
||||
err?.response?.data?.detail ?? err?.response?.data ?? "Ошибка при оформлении заказа"
|
||||
error?.response?.data?.detail ?? error?.response?.data ?? "Ошибка при оформлении заказа"
|
||||
toast.error(String(message))
|
||||
} finally {
|
||||
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"
|
||||
|
||||
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 { api } from "@/shared/api/axios"
|
||||
import { toast } from "sonner"
|
||||
@@ -38,7 +38,7 @@ export function AvatarUploader({ currentAvatarUrl, onUploadSuccess }: AvatarUplo
|
||||
setPreview(currentAvatarUrl)
|
||||
}, [currentAvatarUrl])
|
||||
|
||||
const onCropComplete = React.useCallback((_: any, croppedAreaPixels: CropArea) => {
|
||||
const onCropComplete = React.useCallback((_: unknown, croppedAreaPixels: CropArea) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}, [])
|
||||
|
||||
|
||||
+31
-24
@@ -1,29 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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",
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -2,28 +2,29 @@ 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"
|
||||
import { cn } from "@/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",
|
||||
"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: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
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:
|
||||
"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",
|
||||
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",
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
+10
-12
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -8,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,25 +30,22 @@ const CardHeader = React.forwardRef<
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
|
||||
@@ -57,8 +57,10 @@ export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalP
|
||||
toast.success("Пароль успешно изменён")
|
||||
reset()
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "Ошибка при смене пароля")
|
||||
} catch (error: unknown) {
|
||||
// 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 { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -13,13 +13,13 @@ const Checkbox = React.forwardRef<
|
||||
<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",
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<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" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
|
||||
@@ -1,75 +1,76 @@
|
||||
'use client'
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
interface DialogProps {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||
if (!open) return null
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
// Передаем onOpenChange через контекст или рендерим children с пропсами
|
||||
return <>{children}</>
|
||||
}
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
export function DialogContent({ children, className, onClose, ...props }: DialogContentProps) {
|
||||
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}
|
||||
/>
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...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}
|
||||
</div>
|
||||
</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">
|
||||
<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> { }
|
||||
|
||||
export function DialogHeader({ className, ...props }: DialogHeaderProps) {
|
||||
return (
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> { }
|
||||
|
||||
export function DialogFooter({ className, ...props }: DialogFooterProps) {
|
||||
return (
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
@@ -78,60 +79,44 @@ export function DialogFooter({ className, ...props }: DialogFooterProps) {
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
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)}
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> { }
|
||||
|
||||
export function DialogDescription({ className, ...props }: DialogDescriptionProps) {
|
||||
return (
|
||||
<p
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
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-require-imports
|
||||
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>
|
||||
)
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
@@ -27,14 +27,14 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
@@ -47,7 +47,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -65,7 +65,8 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -83,7 +84,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
|
||||
+18
-17
@@ -1,17 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
@@ -23,9 +24,7 @@ type FormFieldContextValue<
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -45,12 +44,16 @@ const useFormField = () => {
|
||||
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>")
|
||||
}
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("useFormField should be used within <FormItem>")
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
@@ -67,9 +70,7 @@ type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -86,8 +87,8 @@ const FormItem = React.forwardRef<
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof Label>,
|
||||
React.ComponentPropsWithoutRef<typeof Label>
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
@@ -134,7 +135,7 @@ const FormDescription = React.forwardRef<
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -146,7 +147,7 @@ const FormMessage = React.forwardRef<
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
@@ -156,7 +157,7 @@ const FormMessage = React.forwardRef<
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef, useState } from "react";
|
||||
import { uploadOfferImage } from "@/shared/api/catalog";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, ImagePlus, Loader2, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { X, ImagePlus, Loader2 } from "lucide-react";
|
||||
|
||||
interface ImageUploaderProps {
|
||||
value: string[];
|
||||
@@ -37,12 +37,12 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
|
||||
newUrls.push(url);
|
||||
}
|
||||
onChange(newUrls);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error("Не удалось загрузить изображения");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
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'}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, i)}
|
||||
onDragOver={(e) => handleDragOver(e, i)}
|
||||
onDragOver={(e) => handleDragOver(e)}
|
||||
onDrop={(e) => handleDrop(e, i)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<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",
|
||||
"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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
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"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
@@ -60,7 +59,9 @@ export function LocationModal({
|
||||
await updateLocation(data.location || "", data.currentLocation || "")
|
||||
toast.success("Локация успешно обновлена")
|
||||
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 || "Ошибка при обновлении локации")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
@@ -28,13 +28,13 @@ const RadioGroupItem = React.forwardRef<
|
||||
<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",
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<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.Item>
|
||||
)
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
import * as React from "react"
|
||||
import { Star } from "lucide-react"
|
||||
|
||||
import { getRatingColor } from "@/shared/lib/ratingColors"
|
||||
|
||||
interface RatingProps {
|
||||
value: number
|
||||
showValue?: boolean
|
||||
size?: "sm" | "md" | "lg"
|
||||
showCount?: boolean
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
||||
// Определяем цвет звезды на основе рейтинга
|
||||
const getStarColor = () => {
|
||||
if (value >= 4) return "text-green-600 dark:text-green-500"
|
||||
if (value >= 3) return "text-blue-600 dark:text-blue-500"
|
||||
return "text-red-600 dark:text-red-500"
|
||||
export function Rating({ value, showValue = true, size = "md", showCount = false, count }: RatingProps) {
|
||||
// Определяем цвет звезды на основе рейтинга (3 цвета как в профиле)
|
||||
const getStarStyle = () => getRatingColor(value);
|
||||
|
||||
const hasNoRating = value === 0 || (showCount && count === 0);
|
||||
|
||||
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 звезд с процентом заполнения
|
||||
const stars = Array.from({ length: 5 }, (_, index) => {
|
||||
const starValue = index + 1
|
||||
const fillPercentage = Math.max(0, Math.min(100, (value - index) * 100))
|
||||
|
||||
return { index, fillPercentage }
|
||||
@@ -48,7 +53,7 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
||||
style={{ width: `${fillPercentage}%` }}
|
||||
>
|
||||
<Star
|
||||
className={`${sizeClasses[size]} ${getStarColor()} fill-current`}
|
||||
className={`${sizeClasses[size]} ${getStarStyle()}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,11 +61,22 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
|
||||
</div>
|
||||
|
||||
{/* Числовое значение справа */}
|
||||
{showValue && (
|
||||
<span className={`font-semibold ${getStarColor()}`}>
|
||||
{showValue && value > 0 && (
|
||||
<span className={`font-semibold ${getStarStyle().split(' ')[0]}`}>
|
||||
{value.toFixed(2)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
if (!editor) return
|
||||
|
||||
@@ -184,7 +184,9 @@ export function ScheduleModal({
|
||||
await updateSchedule(isAlwaysReady, Object.keys(scheduleData).length > 0 ? scheduleData : undefined)
|
||||
toast.success("Расписание успешно обновлено")
|
||||
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 || "Ошибка при обновлении расписания")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<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",
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
@@ -75,7 +75,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
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" &&
|
||||
"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
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
@@ -16,10 +17,10 @@ const Slider = React.forwardRef<
|
||||
)}
|
||||
{...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.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>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
@@ -16,11 +16,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
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",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toaster]:bg-primary group-[.toaster]:text-primary-foreground",
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toaster]:bg-muted group-[.toaster]:text-muted-foreground",
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
"use client"
|
||||
|
||||
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> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Переключатель (тумблер) — аналог 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)}
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
<SwitchPrimitives.Thumb
|
||||
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"
|
||||
"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"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
Switch.displayName = "Switch"
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ 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",
|
||||
"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
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -16,6 +16,7 @@ interface User {
|
||||
currentLocation?: string;
|
||||
fullName: string;
|
||||
avatarUrl?: string;
|
||||
status?: string;
|
||||
competencies: string[];
|
||||
workSchedule?: {
|
||||
isAlwaysReady: boolean;
|
||||
@@ -35,7 +36,7 @@ interface SessionState {
|
||||
isInitialized: boolean;
|
||||
checkAuth: () => Promise<void>;
|
||||
login: (phone: string, password: string) => Promise<void>;
|
||||
register: (data: any) => Promise<void>;
|
||||
register: (data: unknown) => Promise<void>;
|
||||
logout: () => void;
|
||||
getProfile: () => 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 });
|
||||
try {
|
||||
// Убираем confirmPassword перед отправкой на бэкенд
|
||||
const { confirmPassword, ...registerData } = data;
|
||||
await api.post("/auth/register", registerData);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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 {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { useSessionStore } from "@/entities/session/store"
|
||||
|
||||
type UserRole = "User" | "Candidate" | "Company"
|
||||
// type UserRole = "User" | "Candidate" | "Company"
|
||||
|
||||
const registerSchema = z.object({
|
||||
role: z.enum(["User", "Candidate", "Company"]),
|
||||
@@ -84,6 +84,7 @@ export function RegisterForm() {
|
||||
// Формируем E.164 формат
|
||||
const formattedPhone = cleanPhone.startsWith('7') ? `+${cleanPhone}` : `+7${cleanPhone}`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const submitData: any = {
|
||||
phone: formattedPhone,
|
||||
password: values.password,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import { updateGeoStatus, PerformerState } from "@/shared/api/geo";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__geoInterval?: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
}
|
||||
|
||||
interface GeoState {
|
||||
/** Включён ли тумблер «На линии» */
|
||||
isOnline: boolean;
|
||||
@@ -17,16 +23,30 @@ interface GeoState {
|
||||
goOnline: (performerId: string) => void;
|
||||
/** Выключить тумблер — отправить Offline и остановить отслеживание */
|
||||
goOffline: (performerId: string) => void;
|
||||
/** Принудительно установить состояние (для синхронизации с БД при загрузке) */
|
||||
setOnline: (performerId: string, online: boolean) => void;
|
||||
}
|
||||
|
||||
export const useGeoStore = create<GeoState>((set, get) => ({
|
||||
isOnline: false,
|
||||
isOnline: typeof window !== "undefined" ? localStorage.getItem("performer_is_online") === "true" : false,
|
||||
watchId: null,
|
||||
isUpdating: false,
|
||||
error: 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) => {
|
||||
const { watchId } = get();
|
||||
if (watchId !== null) return; // Уже запущено
|
||||
|
||||
if (typeof window === "undefined" || !navigator.geolocation) {
|
||||
set({ error: "Геолокация не поддерживается вашим браузером." });
|
||||
return;
|
||||
@@ -35,7 +55,7 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
||||
// Таймер для периодической отправки (каждые 10 секунд)
|
||||
let sendInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
const newWatchId = navigator.geolocation.watchPosition(
|
||||
async (position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
set({ lastCoords: { lat: latitude, lon: longitude }, error: null });
|
||||
@@ -89,10 +109,13 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
||||
}, 10000);
|
||||
|
||||
// Сохраняем 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
|
||||
(window as any).__geoInterval = sendInterval;
|
||||
window.__geoInterval = sendInterval;
|
||||
},
|
||||
|
||||
goOffline: async (performerId: string) => {
|
||||
@@ -104,9 +127,9 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
||||
}
|
||||
|
||||
// Останавливаем интервал
|
||||
if ((window as any).__geoInterval) {
|
||||
clearInterval((window as any).__geoInterval);
|
||||
(window as any).__geoInterval = null;
|
||||
if (window.__geoInterval) {
|
||||
clearInterval(window.__geoInterval);
|
||||
window.__geoInterval = null;
|
||||
}
|
||||
|
||||
// Отправляем статус Offline
|
||||
@@ -118,11 +141,14 @@ export const useGeoStore = create<GeoState>((set, get) => ({
|
||||
longitude: lastCoords.lon,
|
||||
state: PerformerState.Offline,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error("[Geo] Ошибка отправки Offline:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue: any[] = [];
|
||||
interface QueueItem {
|
||||
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) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
@@ -32,12 +37,14 @@ const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue = [];
|
||||
};
|
||||
|
||||
// Response interceptor for token refresh
|
||||
// Response interceptor for token refresh and global error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalRequest: any = error.config;
|
||||
|
||||
// 1. Handle 401 Unauthorized (Token Refresh)
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,12 +5,16 @@ export interface Category {
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId: string | null;
|
||||
attributeSchema?: any;
|
||||
attributeSchema?: unknown;
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
id: string;
|
||||
performerId: string;
|
||||
performerName?: string;
|
||||
performerRole?: string;
|
||||
rating?: number;
|
||||
reviewsCount?: number;
|
||||
categoryId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -19,7 +23,7 @@ export interface Offer {
|
||||
currency: string;
|
||||
type: number; // 0 = Fixed, 1 = Hourly, 2 = Negotiable
|
||||
};
|
||||
attributes: Record<string, any> | null;
|
||||
attributes: Record<string, unknown> | null;
|
||||
isActive: boolean;
|
||||
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"
|
||||
| "Completed"
|
||||
| "Cancelled"
|
||||
| "Expired";
|
||||
| "Expired"
|
||||
| "VerificationPending"
|
||||
| "Disputed";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
customerId: string;
|
||||
@@ -32,6 +58,7 @@ export interface Order {
|
||||
cancellationReason: string | null;
|
||||
/** Секунды до истечения SLA (только для PendingAcceptance) */
|
||||
slaSecondsLeft: number | null;
|
||||
dispute: Dispute | null;
|
||||
}
|
||||
|
||||
export interface CreateOrderPayload {
|
||||
@@ -88,3 +115,58 @@ export const cancelOrder = async (
|
||||
): Promise<void> => {
|
||||
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
|
||||
title: string
|
||||
description: string
|
||||
priceAmount?: number
|
||||
priceCurrency?: string
|
||||
amount?: number
|
||||
currency?: string
|
||||
priceAmount?: number;
|
||||
priceCurrency?: string;
|
||||
imageUrl?: string;
|
||||
rating?: number;
|
||||
reviewsCount?: number;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
@@ -19,6 +20,8 @@ export interface SearchResultItem {
|
||||
status: "Готов к заказу" | "Офлайн"
|
||||
distance?: number
|
||||
role?: string
|
||||
rating?: number
|
||||
reviewsCount?: number
|
||||
matchedCompetencies: string[]
|
||||
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"
|
||||
|
||||
import Link from "next/link"
|
||||
import * as React from "react"
|
||||
import { ModeToggle } from "@/components/theme/mode-toggle"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
@@ -19,7 +20,28 @@ import { useGeoStore } from "@/features/geo/useGeoStore"
|
||||
|
||||
export function Header() {
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user