Переписаны модули, тесты, обработка ошибок

This commit is contained in:
Халимов Рустам
2026-03-10 21:08:25 +03:00
parent 878934e705
commit 144f200781
87 changed files with 4363 additions and 1792 deletions
+72 -147
View File
@@ -1,166 +1,91 @@
# ✨ Nashel — Frontend # ✨ Nashel — Frontend
> **Next.js 15 · TypeScript · React · TailwindCSS · Shadcn/UI** > **Next.js 15 (App Router) · TypeScript · Tailwind CSS · Shadcn/UI · Zustand**
Клиентская часть платформы **Nashel** — маркетплейса для поиска и найма профессиональных исполнителей. Реализован как современное SPA/SSR приложение с тёмной темой, интерактивной картой и богатым UX. Клиентская часть платформы **Nashel** современного маркетплейса для поиска и найма профессиональных исполнителей. Приложение спроектировано как высокопроизводительное SPA/SSR решение с глубокой проработкой пользовательского опыта, адаптивным дизайном и интерактивными картами.
--- ---
## 🎯 Какую проблему решает Nashel ## 🎯 Преимущества для пользователя
Клиенты тратят часы на поиск надёжных мастеров. Исполнители теряют заказы из-за отсутствия нормального онлайн-присутствия. **Клиент:**
- **Мгновенный поиск:** Находите мастеров прямо на карте в своем районе.
- **Инфо-карты:** Подробные страницы услуг с галереями работ, честными ценами и атрибутами.
- **Безопасный заказ:** Оформляйте сделки и отслеживайте их статус в реальном времени.
**Nashel** соединяет их мгновенно: **Исполнитель:**
- Ищи мастера прямо на карте рядом с домом - **Цифровой кабинет:** Удобное управление услугами, расписанием и статусом доступности.
- Смотри фото работ, цены и описание услуги - **Галереи работ:** Загрузка и управление фотографиями с поддержкой Drag-and-Drop.
- Нажми одну кнопку — и выйди на связь с исполнителем - **Система рейтинга:** Накапливайте отзывы и повышайте доверие клиентов.
--- ---
## 👤 Роли пользователей ## 🛠️ Технологический стек
| Роль | Возможности |
|------|-------------|
| **Клиент** | Поиск услуг, просмотр профилей, карта |
| **Кандидат в мастера** | Личный кабинет, создание профиля исполнителя |
| **Мастер** | Полный кабинет: управление услугами, расписание, статус |
| **Компания** | Расширенный кабинет с представлением от юрлица |
---
## 🗂️ Структура проекта
```
src/
├── app/ # Страницы (Next.js App Router)
│ ├── (auth)/ # Регистрация, вход
│ ├── dashboard/ # Личный кабинет исполнителя
│ │ ├── offers/ # Управление услугами: список, создание, редактирование
│ │ ├── profile/ # Профиль, аватар, локация, расписание, компетенции
│ │ └── settings/ # Настройки аккаунта
│ ├── offers/[id]/ # Публичная страница просмотра услуги
│ ├── search/ # Поиск услуг + карта
│ └── layout.tsx # Общий layout с навигацией
├── components/ # Переиспользуемые компоненты
│ ├── ui/ # Shadcn UI компоненты (Button, Card, Dialog и т.д.)
│ ├── ImageUploader/ # Компонент загрузки, перетаскивания и удаления фото
│ └── ...
└── shared/
├── api/ # Функции для обращения к API (catalog, search, auth)
└── store/ # Zustand: геолокация пользователя
```
---
## ✅ Что реализовано
### Аутентификация
- Регистрация и вход (JWT в localStorage)
- Защищённые маршруты
- Сохранение сессии
### Карта и поиск
- Интерактивная карта на `React Leaflet` (без SSR)
- Поиск по **названию услуги**, **описанию** и **компетенциям**
- Результаты поиска появляются по мере ввода (от 2 символов)
- Маркеры исполнителей на карте с всплывающими подсказками
- Отображение расстояния до исполнителя (м/км)
- Сортировка: «Идеально подходят», «Ближайшие», «Лучший рейтинг»
- Фильтры поиска: по названию, описанию, компетенциям
- Отображение услуги **приоритетнее** исполнителя (если нашлась услуга — исполнитель отдельно не показывается)
### Карточка услуги в поиске
- Большое фото с hover-анимацией (scale + fade)
- Цена в рублях (₽), расстояние с иконкой
- Краткое описание услуги
- Роль и статус исполнителя (Мастер / Кандидат в мастера / Компания)
- Кнопка «Просмотреть услугу» — открывает страницу в новой вкладке
### Публичная страница услуги (`/offers/[id]`)
- Большой блок с фото-галереей (карусель):
- Навигация мышью (стрелки видны при наведении)
- Навигация клавишами ← →
- Счётчик фото (1/N)
- Превью-ряд внизу (миниатюры) с кольцевой подсветкой активного
- Блок описания с аккуратным заголовком
- Блок характеристик услуги (атрибуты ключ-значение) — под описанием
- Правая колонка: карточка исполнителя (имя, роль, рейтинг, документы)
- Блок «Готовы заказать?» с кнопкой «Связаться с исполнителем»
- Блок похожих услуг из категории (заглушка, готовится к наполнению)
- Адаптивная верстка: мобильная → десктопная
### Профиль исполнителя (личный кабинет)
- Загрузка аватара (превью + сохранение)
- Редактирование описания, компетенций (теги с поиском)
- Установка рабочего расписания по дням недели
- Переход в роль исполнителя
- Смена пароля
### Управление услугами
- Список моих услуг с фото-обложкой
- Создание и редактирование услуги:
- **Блок загрузки фото** (первым, до 10 шт., каждое ≤ 5 МБ)
- Drag-and-drop для перестановки фото мышью
- Кнопка удаления под каждым фото
- Первое фото = обложка услуги
- Название, описание, цена (тип + сумма), атрибуты
- Приостановка услуги (пауза)
- Удаление услуги (soft delete)
- Приостановленные и удалённые услуги не видны в поиске
---
## 🚀 Запуск
```bash
cd nashel-frontend
# Установить зависимости
npm install
# Запустить dev-сервер
npm run dev
```
Приложение доступно по адресу: `http://localhost:3000`
### Переменные окружения
Создать файл `.env.local`:
```env
NEXT_PUBLIC_API_URL=http://localhost:5000
```
---
## 🔧 Технологии
| Технология | Назначение | | Технология | Назначение |
|------------|------------| |------------|------------|
| **Next.js 15** (App Router) | SSR/SPA, маршрутизация | | **Next.js 15** | Фреймворк для SSR, маршрутизации и SEO-оптимизации |
| **TypeScript** | Типизация | | **Tailwind CSS** | Адаптивная верстка и дизайн-система |
| **Tailwind CSS** | Стилизация | | **Shadcn/UI** | Высококачественные доступные компоненты интерфейса |
| **Shadcn/UI** | Готовые UI компоненты | | **Zustand** | Глобальное состояние (геолокация, сессии) |
| **Zustand** | Глобальное состояние (геолокация) | | **React Leaflet** | Интерактивная карта (с динамической подгрузкой) |
| **React Leaflet** | Карта (с отключённым SSR) | | **Axios** | Интеграция с API (авторизация, заказы, репутация) |
| **React Hook Form + Zod** | Формы и валидация | | **React Hook Form** | Комплексная валидация форм (Zod) |
| **Axios** | HTTP-запросы к API |
| **Lucide React** | Иконки | ---
| **Sonner** | Тосты/уведомления |
## ✅ Текущая реализация
### 1. Поиск и Интерактивность
- **Карта:** Отображение маркеров исполнителей, расчет дистанции до пользователя в реальном времени.
- **Smart Search:** Поиск по заголовкам, описаниям и компетенциям с мгновенной фильтрацией.
- **Сортировка:** По расстоянию, релевантности и качеству предложений.
### 2. Страница Услуги (Offer Page)
- **Rich Gallery:**
- Продвинутая карусель с поддержкой клавиатуры (← →).
- Превью-ряд с подсветкой активного фото.
- Hover-эффект увеличения и плавные переходы.
- **Детализация:** Полный список атрибутов услуги, карточка исполнителя, статус верификации.
### 3. Личный кабинет (Dashboard)
- **Управление Офферами:**
- Создание услуг с мультизагрузкой фото (до 10 шт).
- Интерактивный порядок фото (Drag-and-Drop).
- Пауза и мягкое удаление услуг.
- **Профиль:** Смена аватара, настройка сложных расписаний, управление компетенциями.
- **Orders API:** Полная интеграция с бэкенд-модулем заказов (принятие, завершение, споры).
---
## 🚀 Быстрый старт
1. Перейдите в директорию фронтенда:
```bash
cd nashel-frontend
```
2. Установите зависимости:
```bash
npm install
```
3. Создайте `.env.local`:
```env
NEXT_PUBLIC_API_URL=http://localhost:5000
```
4. Запустите в режиме разработки:
```bash
npm run dev
```
Приложение будет доступно по адресу `http://localhost:3000`
--- ---
## 🔮 Планы развития ## 🔮 Планы развития
- [ ] Реальный чат с исполнителем (WebSocket) - [ ] **Real-time Chat:** Чат между клиентом и мастером на WebSockets.
- [ ] Страница профиля исполнителя (публичная) - [ ] **Public Profiles:** Публичные страницы мастеров с портфолио и отзывами.
- [ ] Система отзывов и рейтингов (реальные данные) - [ ] **SEO Boost:** Гидратация мета-тегов для индексации поисковиками.
- [ ] Оформление заказа через платформу - [ ] **Push Notifications:** Уведомления о новых заказах и сообщениях.
- [ ] Push-уведомления - [ ] **Mobile App:** Разработка нативного приложения на React Native.
- [ ] Мобильное приложение (React Native) - [ ] **Verification UI:** Интерфейс загрузки документов для подтверждения статуса.
- [ ] Полнотекстовый фильтр по категориям и тегам
- [ ] Похожие услуги (реальные данные из той же категории)
- [ ] SEO-оптимизация публичных страниц
BIN
View File
Binary file not shown.
-28
View File
@@ -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
BIN
View File
Binary file not shown.
+35 -4
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -17,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tiptap/extension-link": "^3.19.0", "@tiptap/extension-link": "^3.19.0",
"@tiptap/react": "^3.19.0", "@tiptap/react": "^3.19.0",
@@ -34,7 +36,7 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-easy-crop": "^5.5.6", "react-easy-crop": "^5.5.6",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.2",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"sonner": "^1.7.1", "sonner": "^1.7.1",
@@ -1675,6 +1677,35 @@
} }
} }
}, },
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -6815,9 +6846,9 @@
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.71.1", "version": "7.71.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
+4 -2
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -18,6 +19,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tiptap/extension-link": "^3.19.0", "@tiptap/extension-link": "^3.19.0",
"@tiptap/react": "^3.19.0", "@tiptap/react": "^3.19.0",
@@ -35,7 +37,7 @@
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-easy-crop": "^5.5.6", "react-easy-crop": "^5.5.6",
"react-hook-form": "^7.71.1", "react-hook-form": "^7.71.2",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"sonner": "^1.7.1", "sonner": "^1.7.1",
@@ -55,5 +57,5 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
}, },
"packageManager": "pnpm@9.1.4" "packageManager": "npm"
} }
+50
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
@@ -8,7 +8,7 @@ import { z } from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { useSessionStore } from '@/entities/session/store' import { useSessionStore } from '@/entities/session/store'
import { Loader2, Clock, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react' import { Loader2, Clock, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'
@@ -171,11 +171,6 @@ export default function BecomePerformerPage() {
} }
} }
const handleScheduleOpen = () => {
form.setValue('workSchedule.isAlwaysReady', false)
setScheduleCollapsed(false)
}
// Поиск адресов через Nominatim API (как в TagInput - только лучшее совпадение) // Поиск адресов через Nominatim API (как в TagInput - только лучшее совпадение)
React.useEffect(() => { React.useEffect(() => {
const searchAddress = async () => { const searchAddress = async () => {
@@ -194,6 +189,7 @@ export default function BecomePerformerPage() {
} }
) )
const data = await response.json() const data = await response.json()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addresses = data.map((item: any) => { const addresses = data.map((item: any) => {
const city = item.address.city || item.address.town || item.address.village || '' const city = item.address.city || item.address.town || item.address.village || ''
const street = item.address.road || '' const street = item.address.road || ''
@@ -15,13 +15,13 @@ import {
RefreshCw, RefreshCw,
ShoppingBag, ShoppingBag,
User, User,
Star,
MapPin, MapPin,
ArrowRight, ArrowRight,
CreditCard, Gavel,
} from "lucide-react" } from "lucide-react"
import { useSessionStore } from "@/entities/session/store" import { useSessionStore } from "@/entities/session/store"
import { formatPrice } from "@/shared/lib/formatPrice" import { formatPrice } from "@/shared/lib/formatPrice"
import { Badge } from "@/components/ui/badge"
import { import {
Order, Order,
OrderStatus, OrderStatus,
@@ -30,9 +30,18 @@ import {
acceptOrder, acceptOrder,
completeOrder, completeOrder,
cancelOrder, cancelOrder,
finishByPerformer,
confirmOrderCompletion,
} from "@/shared/api/orders" } from "@/shared/api/orders"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ReviewModal } from "@/components/reputation/ReviewModal"
import { DisputeModal } from "@/components/orders/DisputeModal"
import { DisputeResponseModal } from "@/components/orders/DisputeResponseModal"
import { useProfileReviews, useProfileRating } from "@/shared/api/reputation"
import { ReviewCard } from "@/components/reputation/ReviewCard"
import { getRatingBadgeClasses, getRatingStarColor } from "@/shared/lib/ratingColors"
import { DisputeDetailsModal } from "@/components/orders/DisputeDetailsModal"
// ─── SLA Countdown ────────────────────────────────────────────────────────── // ─── SLA Countdown ──────────────────────────────────────────────────────────
@@ -109,6 +118,16 @@ const STATUS_CONFIG: Record<
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400", "bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
icon: <AlertCircle className="h-3 w-3" />, icon: <AlertCircle className="h-3 w-3" />,
}, },
VerificationPending: {
label: "На проверке",
className: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300",
icon: <Clock className="h-3 w-3" />,
},
Disputed: {
label: "В споре",
className: "bg-rose-100 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300",
icon: <AlertCircle className="h-3 w-3" />,
},
} }
function StatusBadge({ status }: { status: OrderStatus }) { function StatusBadge({ status }: { status: OrderStatus }) {
@@ -131,10 +150,21 @@ function StatusBadge({ status }: { status: OrderStatus }) {
interface OrderCardProps { interface OrderCardProps {
order: Order order: Order
userId: string userId: string
isPerformerRole: boolean onOpenReview?: (orderId: string, offerId: string, authorId: string, targetId: string, targetName: string) => void
onOpenDispute?: (orderId: string, customerId: string) => void
onOpenDisputeResponse?: (orderId: string, performerId: string) => void
onOpenDisputeDetails?: (order: Order) => void
} }
function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) { const DISPUTE_REASONS_MAP: Record<string, string> = {
"WorkNotPerformed": "Работы не выполнены",
"NotFullVolume": "Выполнены не в полном объеме",
"PoorQuality": "Выполнены некачественно",
"DeadlineViolated": "Нарушен срок",
"PriceIncreased": "Увеличилась стоимость",
}
function OrderCard({ order, userId, onOpenReview, onOpenDispute, onOpenDisputeResponse, onOpenDisputeDetails }: OrderCardProps) {
const qc = useQueryClient() const qc = useQueryClient()
const router = useRouter() const router = useRouter()
@@ -142,40 +172,67 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
qc.invalidateQueries({ queryKey: ["orders"] }) qc.invalidateQueries({ queryKey: ["orders"] })
} }
const acceptMutation = useMutation({ const mutateAccept = useMutation({
mutationFn: () => acceptOrder(order.id, userId), mutationFn: () => acceptOrder(order.id, userId),
onSuccess: () => { onSuccess: () => {
toast.success("Заказ принят!") toast.success("Заказ принят!")
invalidate() invalidate()
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) => onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"), toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
}) })
const completeMutation = useMutation({ const mutateComplete = useMutation({
mutationFn: () => completeOrder(order.id, userId), mutationFn: () => completeOrder(order.id, userId),
onSuccess: () => { onSuccess: () => {
toast.success("Заказ завершён!") toast.success("Заказ завершён!")
invalidate() invalidate()
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) => onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"), toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
}) })
const cancelMutation = useMutation({ const mutateCancel = useMutation({
mutationFn: (reason: string) => cancelOrder(order.id, userId, reason), mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
onSuccess: () => { onSuccess: () => {
toast.success("Заказ отменён") toast.success("Заказ отменён")
invalidate() invalidate()
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) => onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"), toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
}) })
const mutateFinishByPerformer = useMutation({
mutationFn: () => finishByPerformer(order.id, userId),
onSuccess: () => {
toast.success("Работа отправлена на проверку!")
invalidate()
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении работы"),
})
const mutateConfirmCompletion = useMutation({
mutationFn: () => confirmOrderCompletion(order.id, userId),
onSuccess: () => {
toast.success("Выполнение подтверждено. Заказ завершён!")
invalidate()
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при подтверждении"),
})
const isLoading = const isLoading =
acceptMutation.isPending || mutateAccept.isPending ||
completeMutation.isPending || mutateComplete.isPending ||
cancelMutation.isPending mutateCancel.isPending ||
mutateFinishByPerformer.isPending ||
mutateConfirmCompletion.isPending
const isCustomer = order.customerId === userId const isCustomer = order.customerId === userId
@@ -262,6 +319,27 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
</div> </div>
</div> </div>
{/* Инфо о споре */}
{order.status === "Disputed" && order.dispute && (
<div
onClick={() => onOpenDisputeDetails?.(order)}
className="flex flex-col gap-3 p-4 rounded-2xl bg-rose-500/5 border border-rose-500/10 shadow-inner cursor-pointer hover:bg-rose-500/10 transition-colors group/dispute"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Gavel className="h-4 w-4 text-rose-600 shrink-0" />
<div>
<p className="text-[10px] uppercase font-black text-rose-600/60 tracking-widest">Идет спор</p>
<p className="text-[12px] font-bold text-rose-700">{DISPUTE_REASONS_MAP[order.dispute.reason] || order.dispute.reason}</p>
</div>
</div>
<Button variant="ghost" size="sm" className="h-8 rounded-full text-[10px] font-bold uppercase tracking-tight text-rose-600 hover:bg-rose-500/20">
Детали
</Button>
</div>
</div>
)}
{/* Доп. инфо (отмена) */} {/* Доп. инфо (отмена) */}
{order.cancellationReason && ( {order.cancellationReason && (
<div className="flex items-start gap-3 p-4 rounded-2xl bg-red-500/5 text-[12px] text-red-600 dark:text-red-400 border border-red-500/10 shadow-inner"> <div className="flex items-start gap-3 p-4 rounded-2xl bg-red-500/5 text-[12px] text-red-600 dark:text-red-400 border border-red-500/10 shadow-inner">
@@ -281,9 +359,9 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
<Button <Button
className="rounded-2xl h-12 shadow-lg shadow-primary/20 bg-primary hover:bg-primary/90 font-bold text-sm" className="rounded-2xl h-12 shadow-lg shadow-primary/20 bg-primary hover:bg-primary/90 font-bold text-sm"
disabled={isLoading} disabled={isLoading}
onClick={() => acceptMutation.mutate()} onClick={() => mutateAccept.mutate()}
> >
{acceptMutation.isPending ? ( {mutateAccept.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
"Принять заказ" "Принять заказ"
@@ -293,9 +371,9 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
variant="outline" variant="outline"
className="rounded-2xl h-12 border-red-500/20 text-red-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all font-bold text-sm" className="rounded-2xl h-12 border-red-500/20 text-red-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all font-bold text-sm"
disabled={isLoading} disabled={isLoading}
onClick={() => cancelMutation.mutate("Отклонён мастером")} onClick={() => mutateCancel.mutate("Отклонён мастером")}
> >
{cancelMutation.isPending ? ( {mutateCancel.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
"Отклонить" "Отклонить"
@@ -304,14 +382,14 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
</div> </div>
)} )}
{/* Завершить работу (мастер или клиент) */} {/* Завершить работу (мастер) */}
{order.status === "InProgress" && ( {!isCustomer && order.status === "InProgress" && (
<Button <Button
className="w-full rounded-2xl h-12 shadow-lg shadow-blue-500/20 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold text-sm" className="w-full rounded-2xl h-12 shadow-lg shadow-blue-500/20 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold text-sm"
disabled={isLoading} disabled={isLoading}
onClick={() => completeMutation.mutate()} onClick={() => mutateFinishByPerformer.mutate()}
> >
{completeMutation.isPending ? ( {mutateFinishByPerformer.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
"Завершить работу" "Завершить работу"
@@ -319,21 +397,73 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
</Button> </Button>
)} )}
{/* Клиент: Подтвердить / Оспорить */}
{isCustomer && order.status === "VerificationPending" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
className="rounded-2xl h-12 shadow-lg shadow-green-500/20 bg-green-600 hover:bg-green-700 text-white font-bold text-sm"
disabled={isLoading}
onClick={() => mutateConfirmCompletion.mutate()}
>
{mutateConfirmCompletion.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
Подтвердить
</>
)}
</Button>
<Button
variant="outline"
className="rounded-2xl h-12 border-rose-500/20 text-rose-600 hover:bg-rose-500 hover:text-white transition-all font-bold text-sm"
disabled={isLoading}
onClick={() => onOpenDispute?.(order.id, userId)}
>
<AlertCircle className="h-4 w-4 mr-2" />
Оспорить
</Button>
</div>
)}
{/* Мастер: Ответить на спор */}
{!isCustomer && order.status === "Disputed" && (
<Button
className="w-full rounded-2xl h-12 shadow-lg shadow-rose-500/20 bg-rose-600 hover:bg-rose-700 text-white font-bold text-sm"
disabled={isLoading}
onClick={() => onOpenDisputeResponse?.(order.id, userId)}
>
<Gavel className="h-4 w-4 mr-2" />
Ответить на спор
</Button>
)}
{/* Клиент: Отменить (пока мастер не принял) */} {/* Клиент: Отменить (пока мастер не принял) */}
{isCustomer && order.status === "PendingAcceptance" && ( {isCustomer && order.status === "PendingAcceptance" && (
<Button <Button
variant="ghost" variant="ghost"
className="w-full rounded-2xl h-12 text-muted-foreground hover:text-red-500 hover:bg-red-500/5 transition-all font-bold text-sm" className="w-full rounded-2xl h-12 text-muted-foreground hover:text-red-500 hover:bg-red-500/5 transition-all font-bold text-sm"
disabled={isLoading} disabled={isLoading}
onClick={() => cancelMutation.mutate("Отменён заказчиком")} onClick={() => mutateCancel.mutate("Отменён заказчиком")}
> >
{cancelMutation.isPending ? ( {mutateCancel.isPending ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
"Отменить заказ" "Отменить заказ"
)} )}
</Button> </Button>
)} )}
{/* Клиент: Оценить работу (для завершённых заказов) */}
{isCustomer && order.status === "Completed" && order.performerId && (
<Button
className="w-full rounded-2xl h-12 shadow-lg shadow-indigo-500/20 bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-sm"
onClick={() => onOpenReview?.(order.id, order.serviceId, userId, order.performerId!, order.performerName || 'Мастер')}
>
<Star className="h-4 w-4 mr-2" />
Оценить работу
</Button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -343,10 +473,10 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
// ─── Главная страница ───────────────────────────────────────────────────────── // ─── Главная страница ─────────────────────────────────────────────────────────
const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"] const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"] const PROGRESS_STATUSES: OrderStatus[] = ["InProgress", "VerificationPending", "Disputed"]
const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"] const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
type OrderTab = "new" | "process" | "done" type OrderTab = "new" | "process" | "done" | "reviews"
export default function OrdersDashboardPage() { export default function OrdersDashboardPage() {
const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore() const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
@@ -355,6 +485,86 @@ export default function OrdersDashboardPage() {
const [customerTab, setCustomerTab] = React.useState<OrderTab>("new") const [customerTab, setCustomerTab] = React.useState<OrderTab>("new")
const [performerTab, setPerformerTab] = React.useState<OrderTab>("new") const [performerTab, setPerformerTab] = React.useState<OrderTab>("new")
// Состояние для модалки отзыва
const [reviewModal, setReviewModal] = React.useState<{
isOpen: boolean;
orderId: string;
offerId: string;
authorId: string;
targetId: string;
targetName: string;
}>({
isOpen: false,
orderId: '',
offerId: '',
authorId: '',
targetId: '',
targetName: ''
})
const openReviewModal = (orderId: string, offerId: string, authorId: string, targetId: string, targetName: string) => {
setReviewModal({ isOpen: true, orderId, offerId, authorId, targetId, targetName })
}
const closeReviewModal = () => {
setReviewModal(prev => ({ ...prev, isOpen: false }))
}
// Состояние для модалки спора
const [disputeModal, setDisputeModal] = React.useState<{
isOpen: boolean;
orderId: string;
customerId: string;
}>({
isOpen: false,
orderId: '',
customerId: '',
})
const openDisputeModal = (orderId: string, customerId: string) => {
setDisputeModal({ isOpen: true, orderId, customerId })
}
const closeDisputeModal = () => {
setDisputeModal(prev => ({ ...prev, isOpen: false }))
}
// Состояние для модалки ответа на спор
const [disputeResponseModal, setDisputeResponseModal] = React.useState<{
isOpen: boolean;
orderId: string;
performerId: string;
}>({
isOpen: false,
orderId: '',
performerId: '',
})
const openDisputeResponseModal = (orderId: string, performerId: string) => {
setDisputeResponseModal({ isOpen: true, orderId, performerId })
}
const closeDisputeResponseModal = () => {
setDisputeResponseModal(prev => ({ ...prev, isOpen: false }))
}
// Состояние для модалки деталей спора (новая)
const [disputeDetailsModal, setDisputeDetailsModal] = React.useState<{
isOpen: boolean;
order: Order | null;
}>({
isOpen: false,
order: null,
})
const openDisputeDetailsModal = (order: Order) => {
setDisputeDetailsModal({ isOpen: true, order })
}
const closeDisputeDetailsModal = () => {
setDisputeDetailsModal(prev => ({ ...prev, isOpen: false, order: null }))
}
// Редирект если не авторизован // Редирект если не авторизован
React.useEffect(() => { React.useEffect(() => {
if (isInitialized && !isAuthLoading && !isAuth) { if (isInitialized && !isAuthLoading && !isAuth) {
@@ -424,11 +634,14 @@ export default function OrdersDashboardPage() {
allOrdersMap.set(o.id, { order: o, isPerformerRole: true }) allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
} }
}) })
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allOrders = Array.from(allOrdersMap.values()) const allOrders = Array.from(allOrdersMap.values())
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isLoading = const isLoading =
(customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data (customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
const isFetching = customerQuery.isFetching || performerQuery.isFetching const isFetching = customerQuery.isFetching || performerQuery.isFetching
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const error = customerQuery.error || performerQuery.error const error = customerQuery.error || performerQuery.error
const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => { const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
@@ -450,6 +663,9 @@ export default function OrdersDashboardPage() {
const customerCounts = getCounts(customerQuery.data) const customerCounts = getCounts(customerQuery.data)
const performerCounts = getCounts(performerQuery.data) const performerCounts = getCounts(performerQuery.data)
const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(user?.id)
const { data: ratingData } = useProfileRating(user?.id)
// Ждём инициализации сессии // Ждём инициализации сессии
if (!isInitialized || isAuthLoading) { if (!isInitialized || isAuthLoading) {
return ( return (
@@ -493,7 +709,7 @@ export default function OrdersDashboardPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 items-start"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 items-start">
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */} {/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
<section className="space-y-5"> <section className="space-y-5">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
@@ -556,7 +772,7 @@ export default function OrdersDashboardPage() {
</div> </div>
) : ( ) : (
getFilteredOrders(customerQuery.data, customerTab).map(order => ( getFilteredOrders(customerQuery.data, customerTab).map(order => (
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={false} /> <OrderCard key={order.id} order={order} userId={user.id} onOpenReview={openReviewModal} onOpenDispute={openDisputeModal} onOpenDisputeResponse={openDisputeResponseModal} onOpenDisputeDetails={openDisputeDetailsModal} />
)) ))
) )
)} )}
@@ -634,7 +850,7 @@ export default function OrdersDashboardPage() {
</div> </div>
) : ( ) : (
getFilteredOrders(performerQuery.data, performerTab).map(order => ( getFilteredOrders(performerQuery.data, performerTab).map(order => (
<OrderCard key={order.id} order={order} userId={user.id} isPerformerRole={true} /> <OrderCard key={order.id} order={order} userId={user.id} onOpenReview={openReviewModal} onOpenDispute={openDisputeModal} onOpenDisputeResponse={openDisputeResponseModal} onOpenDisputeDetails={openDisputeDetailsModal} />
)) ))
) )
)} )}
@@ -642,7 +858,83 @@ export default function OrdersDashboardPage() {
</> </>
)} )}
</section> </section>
{/* 🌟 ВИДЖЕТ: ОТЗЫВЫ (Я МАСТЕР) */}
<section className="space-y-5">
<div className="flex items-center justify-between px-2">
<div className="space-y-0.5">
<h2 className="text-xl font-bold flex items-center gap-2">
<Star className="h-5 w-5 text-indigo-500" />
Мои отзывы
</h2>
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">Что говорят клиенты</p>
</div>
{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> </div>
{/* Модалка отзыва */}
<ReviewModal
isOpen={reviewModal.isOpen}
onClose={closeReviewModal}
orderId={reviewModal.orderId}
offerId={reviewModal.offerId}
authorId={reviewModal.authorId}
targetId={reviewModal.targetId}
targetName={reviewModal.targetName}
/>
{/* Модалка спора */}
<DisputeModal
isOpen={disputeModal.isOpen}
onClose={closeDisputeModal}
orderId={disputeModal.orderId}
customerId={disputeModal.customerId}
/>
{/* Модалка ответа на спор */}
<DisputeResponseModal
isOpen={disputeResponseModal.isOpen}
onClose={closeDisputeResponseModal}
orderId={disputeResponseModal.orderId}
performerId={disputeResponseModal.performerId}
/>
{/* Модалка деталей спора (новая) */}
{disputeDetailsModal.order && (
<DisputeDetailsModal
isOpen={disputeDetailsModal.isOpen}
onClose={closeDisputeDetailsModal}
order={disputeDetailsModal.order}
userId={user.id}
/>
)}
</div> </div>
) )
} }
@@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -24,10 +23,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Plus, Trash2 } from "lucide-react"; import { Loader2, Plus, Trash2 } from "lucide-react";
import { updateOffer, getOfferById, Category } from "@/shared/api/catalog"; import { updateOffer, getOfferById } from "@/shared/api/catalog";
import { useSessionStore } from "@/entities/session/store"; import { useSessionStore } from "@/entities/session/store";
import { ImageUploader } from "@/components/ui/image-uploader"; import { ImageUploader } from "@/components/ui/image-uploader";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@@ -111,6 +110,8 @@ export default function EditOfferPage() {
images: offerData.images || [], images: offerData.images || [],
}); });
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error(error);
toast.error("Не удалось загрузить данные услуги"); toast.error("Не удалось загрузить данные услуги");
router.push("/dashboard/offers"); router.push("/dashboard/offers");
} finally { } finally {
@@ -145,6 +146,8 @@ export default function EditOfferPage() {
toast.success("Услуга успешно обновлена"); toast.success("Услуга успешно обновлена");
router.push("/dashboard/offers"); router.push("/dashboard/offers");
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error(error);
toast.error("Не удалось обновить услугу. Проверьте данные и попробуйте снова."); toast.error("Не удалось обновить услугу. Проверьте данные и попробуйте снова.");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -24,7 +24,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Plus, Trash2 } from "lucide-react"; import { Loader2, Plus, Trash2 } from "lucide-react";
import { getCategories, createOffer, Category } from "@/shared/api/catalog"; import { getCategories, createOffer, Category } from "@/shared/api/catalog";
@@ -93,6 +93,8 @@ export default function CreateOfferPage() {
const data = await getCategories(); const data = await getCategories();
setCategories(data || []); setCategories(data || []);
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error(error);
toast.error("Ошибка загрузки категорий"); toast.error("Ошибка загрузки категорий");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -126,6 +128,8 @@ export default function CreateOfferPage() {
toast.success("Услуга успешно создана"); toast.success("Услуга успешно создана");
router.push("/dashboard/offers"); router.push("/dashboard/offers");
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error(error);
toast.error("Не удалось создать услугу. Проверьте данные и попробуйте снова."); toast.error("Не удалось создать услугу. Проверьте данные и попробуйте снова.");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -286,7 +290,7 @@ export default function CreateOfferPage() {
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<div> <div>
<h3 className="text-sm font-medium leading-none mb-1">Дополнительные характеристики</h3> <h3 className="text-sm font-medium leading-none mb-1">Дополнительные характеристики</h3>
<p className="text-sm text-muted-foreground">Добавьте важные детали ("Опыт", "Гарантия")</p> <p className="text-sm text-muted-foreground">Добавьте важные детали (&quot;Опыт&quot;, &quot;Гарантия&quot;)</p>
</div> </div>
<Button <Button
type="button" type="button"
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle, Image as ImageIcon } from "lucide-react"; import { PlusCircle, Loader2, Pencil, Trash2, PauseCircle, PlayCircle } from "lucide-react";
import { useSessionStore } from "@/entities/session/store"; import { useSessionStore } from "@/entities/session/store";
import { formatPrice } from "@/shared/lib/formatPrice"; import { formatPrice } from "@/shared/lib/formatPrice";
@@ -32,8 +32,7 @@ export default function MyOffersPage() {
try { try {
const data = await getMyOffers(); const data = await getMyOffers();
setOffers(data || []); setOffers(data || []);
} catch (error) { } catch {
console.error(error);
toast.error("Не удалось загрузить ваши услуги"); toast.error("Не удалось загрузить ваши услуги");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/entities/session/store"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Loader2, User, Phone, ShieldCheck, Hammer, Award, Clock, MapPin, Trash2, Edit, Key, LogOut } from "lucide-react" import { Loader2, User, Phone, ShieldCheck, Hammer, Award, Clock, MapPin, Trash2, Edit, Key } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
@@ -19,7 +19,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { formatPhoneNumber } from "@/shared/lib/utils" import { formatPhoneNumber } from "@/shared/lib/utils"
import { AvatarUploader } from "@/components/ui/avatar-uploader" import { AvatarUploader } from "@/components/ui/avatar-uploader"
import { TagInput } from "@/components/ui/tag-input" import { TagInput } from "@/components/ui/tag-input"
import { Rating, useRandomRating } from "@/components/ui/rating" import { Rating } from "@/components/ui/rating"
import { useProfileRating } from "@/shared/api/reputation"
import { DescriptionViewer } from "@/components/ui/description-viewer" import { DescriptionViewer } from "@/components/ui/description-viewer"
import { ScheduleViewer } from "@/components/ui/schedule-viewer" import { ScheduleViewer } from "@/components/ui/schedule-viewer"
import { ChangePasswordModal } from "@/components/ui/change-password-modal" import { ChangePasswordModal } from "@/components/ui/change-password-modal"
@@ -67,7 +68,7 @@ export default function ProfilePage() {
const [isLocationModalOpen, setIsLocationModalOpen] = React.useState(false) const [isLocationModalOpen, setIsLocationModalOpen] = React.useState(false)
const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false) const [isScheduleModalOpen, setIsScheduleModalOpen] = React.useState(false)
const rating = useRandomRating() const { data: ratingData, isLoading: ratingLoading } = useProfileRating(user?.id)
React.useEffect(() => { React.useEffect(() => {
if (!isInitialized) return if (!isInitialized) return
@@ -77,7 +78,7 @@ export default function ProfilePage() {
} else if (isAuth && !user) { } else if (isAuth && !user) {
getProfile() getProfile()
} }
}, [isAuth, isLoading, user, router, isInitialized]) }, [isAuth, isLoading, user, router, isInitialized, getProfile])
const profileForm = useForm<ProfileValues>({ const profileForm = useForm<ProfileValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -110,6 +111,8 @@ export default function ProfilePage() {
setIsEditingProfile(false) setIsEditingProfile(false)
toast.success("Профиль успешно обновлен") toast.success("Профиль успешно обновлен")
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error(error)
toast.error("Ошибка при обновлении профиля") toast.error("Ошибка при обновлении профиля")
} }
} }
@@ -120,6 +123,8 @@ export default function ProfilePage() {
setIsChangingPhone(false) setIsChangingPhone(false)
toast.success("Номер телефона успешно изменен") toast.success("Номер телефона успешно изменен")
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.error(error)
toast.error("Ошибка при смене номера телефона") toast.error("Ошибка при смене номера телефона")
} }
} }
@@ -187,6 +192,7 @@ export default function ProfilePage() {
(isMaster || isCompany) && user.location && { id: "location", label: "Локация", icon: MapPin }, (isMaster || isCompany) && user.location && { id: "location", label: "Локация", icon: MapPin },
(isMaster || isCompany) && { id: "schedule", label: "График работы", icon: Clock }, (isMaster || isCompany) && { id: "schedule", label: "График работы", icon: Clock },
{ id: "account", label: "Управление аккаунтом", icon: ShieldCheck }, { id: "account", label: "Управление аккаунтом", icon: ShieldCheck },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
].filter(Boolean) as { id: string, label: string, icon: any }[] ].filter(Boolean) as { id: string, label: string, icon: any }[]
return ( return (
@@ -207,7 +213,13 @@ export default function ProfilePage() {
<Badge variant="secondary" className="text-sm font-medium px-3 py-0.5 whitespace-nowrap bg-muted hover:bg-muted text-muted-foreground border-transparent"> <Badge variant="secondary" className="text-sm font-medium px-3 py-0.5 whitespace-nowrap bg-muted hover:bg-muted text-muted-foreground border-transparent">
{translateRole(getHighestRole(user.roles))} {translateRole(getHighestRole(user.roles))}
</Badge> </Badge>
<Rating value={rating} size="sm" /> {ratingLoading ? (
<span className="text-sm text-muted-foreground">Загрузка...</span>
) : ratingData ? (
<Rating value={ratingData.averageRating} size="sm" showCount count={ratingData.totalReviews} />
) : (
<Rating value={0} size="sm" />
)}
</div> </div>
</div> </div>
</div> </div>
+14
View File
@@ -0,0 +1,14 @@
import { Header } from "@/widgets/Header";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
);
}
@@ -4,10 +4,15 @@ import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation" import { useParams, useRouter } from "next/navigation"
import { getOfferById, Offer } from "@/shared/api/catalog" import { getOfferById, Offer } from "@/shared/api/catalog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, Clock, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag } from "lucide-react" import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag, Loader2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { CreateOrderModal } from "@/components/orders/CreateOrderModal" import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
import { formatPrice } from "@/shared/lib/formatPrice" import { formatPrice } from "@/shared/lib/formatPrice"
import { useProfileRating, useProfileReviews, useOfferRating } from "@/shared/api/reputation"
import { usePublicProfile } from "@/shared/api/identity"
import { Rating } from "@/components/ui/rating"
import { ReviewCard } from "@/components/reputation/ReviewCard"
export default function PublicOfferPage() { export default function PublicOfferPage() {
const params = useParams() const params = useParams()
@@ -30,7 +35,9 @@ export default function PublicOfferPage() {
if (data.images && data.images.length > 0) { if (data.images && data.images.length > 0) {
setActiveImageIndex(0) setActiveImageIndex(0)
} }
} catch (error: any) { } catch (err: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = err as any
toast.error("Не удалось загрузить услугу") toast.error("Не удалось загрузить услугу")
console.error("Fetch offer error:", error) console.error("Fetch offer error:", error)
} finally { } finally {
@@ -41,6 +48,11 @@ export default function PublicOfferPage() {
fetchOffer() fetchOffer()
}, [offerId]) }, [offerId])
const { data: ratingData } = useProfileRating(offer?.performerId)
const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(offer?.performerId)
const { data: offerRatingData } = useOfferRating(offerId)
const { data: performerPublicData } = usePublicProfile(offer?.performerId)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!offer || !offer.images || offer.images.length <= 1) return; if (!offer || !offer.images || offer.images.length <= 1) return;
@@ -206,13 +218,50 @@ export default function PublicOfferPage() {
{Object.entries(offer.attributes || {}).map(([key, value]) => ( {Object.entries(offer.attributes || {}).map(([key, value]) => (
<div key={key} className="flex justify-between items-center text-sm md:text-base border-b border-muted/60 pb-3 last:border-0 last:pb-0 pt-1"> <div key={key} className="flex justify-between items-center text-sm md:text-base border-b border-muted/60 pb-3 last:border-0 last:pb-0 pt-1">
<span className="text-muted-foreground">{key}</span> <span className="text-muted-foreground">{key}</span>
<span className="font-semibold text-right max-w-xs break-words">{value}</span> <span className="font-semibold text-right max-w-xs break-words">{String(value)}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Отзывы */}
<div className="bg-card border rounded-xl shadow-sm mt-8 overflow-hidden">
<div className="bg-muted/30 px-6 py-4 border-b flex items-center justify-between">
<h3 className="text-xl font-bold flex flex-col gap-1">
<div className="flex items-center gap-2">
<Star className="w-5 h-5 text-indigo-500" />
Отзывы ({ratingData?.totalReviews || 0})
</div>
{offerRatingData && offerRatingData.totalReviews > 0 && (
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground mt-2">
<span className="text-foreground">Рейтинг услуги: {offerRatingData.averageRating.toFixed(1)}</span>
<Rating value={offerRatingData.averageRating} size="sm" showValue={false} />
</div>
)}
</h3>
</div>
<div className="p-6">
{isReviewsLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-3">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-sm font-medium italic">Загрузка отзывов...</span>
</div>
) : !reviewsData || reviewsData.reviews.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground bg-muted/20 rounded-xl border border-dashed">
<Star className="w-12 h-12 mb-3 opacity-20" />
<p className="italic">Об этом мастере пока нет отзывов.</p>
</div>
) : (
<div className="space-y-6">
{reviewsData.reviews.map(review => (
<ReviewCard key={review.id} review={review} />
))}
</div>
)}
</div>
</div>
</div> </div>
{/* Правая колонка - Карточка заказа / Информация */} {/* Правая колонка - Карточка заказа / Информация */}
@@ -221,39 +270,38 @@ export default function PublicOfferPage() {
<div className="bg-card border rounded-xl p-6 shadow-sm"> <div className="bg-card border rounded-xl p-6 shadow-sm">
<h3 className="text-lg font-bold mb-4">Об исполнителе</h3> <h3 className="text-lg font-bold mb-4">Об исполнителе</h3>
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center text-white shrink-0"> <div className="w-14 h-14 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center text-white shrink-0 shadow-lg">
<User className="w-6 h-6" /> <User className="w-6 h-6" />
</div> </div>
<div> <div className="overflow-hidden">
<h4 className="font-bold text-lg">Имя Фамилия</h4> <h4 className="font-bold text-lg truncate flex items-center gap-2">
<p className="text-sm font-medium text-muted-foreground bg-muted inline-block px-2 py-0.5 rounded mt-1">Мастер</p> <span title="Личность подтверждена">
<ShieldCheck className="w-5 h-5 text-green-500 shrink-0" />
</span>
{performerPublicData?.fullName || offer.performerName || 'Мастер'}
</h4>
<p className="text-[10px] font-black text-muted-foreground uppercase bg-muted inline-block px-2 py-0.5 rounded mt-1 tracking-wider">
{performerPublicData?.primaryRole || offer.performerRole || 'Специалист'}
</p>
</div> </div>
</div> </div>
<div className="flex gap-4 border-b pb-4 mb-4"> <div className="flex flex-col gap-4 border-b pb-6 mb-4">
<div className="flex flex-col"> <div className="flex items-center justify-between">
<span className="text-2xl font-black flex items-center gap-1"> <div className="flex flex-col">
5.0 <Star className="w-5 h-5 fill-yellow-400 text-yellow-400" /> <div className="flex items-center gap-2 mb-1">
</span> <span className="text-3xl font-black text-foreground">{ratingData?.averageRating.toFixed(1) || '0.0'}</span>
<span className="text-xs text-muted-foreground underline underline-offset-2 cursor-pointer hover:text-foreground transition-colors"> <span className="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">
42 отзыва / 5.0
</span> </span>
</div> </div>
<div className="w-px bg-border my-1" /> <Rating value={ratingData?.averageRating || 0} size="md" showValue={false} showCount count={ratingData?.totalReviews} />
<div className="flex flex-col justify-center"> </div>
<span className="text-sm font-medium flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-green-500" />
Документы проверены
</span>
<span className="text-sm font-medium flex items-center gap-1.5 mt-1 text-muted-foreground">
<Clock className="w-4 h-4" />
На сайте 2 года
</span>
</div> </div>
</div> </div>
<Button className="w-full" variant="outline"> <Button className="w-full rounded-xl font-bold bg-muted hover:bg-muted-foreground/10 text-foreground transition-all" variant="ghost">
Перейти в профиль В профиль
</Button> </Button>
</div> </div>
@@ -1,15 +1,25 @@
"use client" "use client"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import * as React from "react"
import { useState, useCallback, useRef, useEffect, useMemo } from "react" import { useState, useCallback, useRef, useEffect, useMemo } from "react"
import { Search, User, Filter, MapPin, Briefcase } from "lucide-react" import {
import { SearchResultItem, SearchParams, globalSearch } from "@/shared/api/search" Search as SearchIcon,
MapPin,
Star,
Hammer,
User,
SearchCheck,
ListOrdered
} from "lucide-react"
import { globalSearch, SearchResultItem } from "@/shared/api/search"
import { getRatingBadgeClasses, getRatingStarColor } from "@/shared/lib/ratingColors"
import { formatPrice } from "@/shared/lib/formatPrice" import { formatPrice } from "@/shared/lib/formatPrice"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
// ВАЖНО: react-leaflet ломает SSR — импортируем с ssr: false
const LiveMap = dynamic(() => import("@/components/map/LiveMap"), { const LiveMap = dynamic(() => import("@/components/map/LiveMap"), {
ssr: false, ssr: false,
loading: () => ( loading: () => (
@@ -31,18 +41,15 @@ export default function SearchPage() {
const [centerTrigger, setCenterTrigger] = useState(0) const [centerTrigger, setCenterTrigger] = useState(0)
const [sortBy, setSortBy] = useState<string>("default") const [sortBy, setSortBy] = useState<string>("default")
// Параметры поиска
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [inTitle, setInTitle] = useState(true) const [inTitle, setInTitle] = useState(true)
const [inDesc, setInDesc] = useState(true) const [inDesc, setInDesc] = useState(true)
const [inComp, setInComp] = useState(true) const [inComp, setInComp] = useState(true)
// Текущая позиция карты (и инициализация из localStorage)
const [mapBounds, setMapBounds] = useState<{ lat: number, lon: number, radius: number } | null>(null) const [mapBounds, setMapBounds] = useState<{ lat: number, lon: number, radius: number } | null>(null)
const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173]) // Default: Moscow const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173])
const [isLocationLoaded, setIsLocationLoaded] = useState(false) const [, setIsLocationLoaded] = useState(false)
// Пытаемся восстановить локацию при монтировании
useEffect(() => { useEffect(() => {
try { try {
const savedProfile = localStorage.getItem("user-profile") const savedProfile = localStorage.getItem("user-profile")
@@ -88,7 +95,6 @@ export default function SearchPage() {
} }
}, []) }, [])
// Debounced search trigger
const triggerSearch = useCallback((newQuery: string, lat?: number, lon?: number, rad?: number, title?: boolean, desc?: boolean, comp?: boolean) => { const triggerSearch = useCallback((newQuery: string, lat?: number, lon?: number, rad?: number, title?: boolean, desc?: boolean, comp?: boolean) => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
@@ -96,7 +102,7 @@ export default function SearchPage() {
const currentLon = lon ?? mapBounds?.lon const currentLon = lon ?? mapBounds?.lon
const currentRad = rad ?? mapBounds?.radius const currentRad = rad ?? mapBounds?.radius
if (!currentLat || !currentLon) return // Ждём, пока карта даст координаты if (!currentLat || !currentLon) return
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
performSearch( performSearch(
@@ -123,18 +129,15 @@ export default function SearchPage() {
const handlePerformerClick = useCallback((id: string) => { const handlePerformerClick = useCallback((id: string) => {
setActivePerformerId(id) setActivePerformerId(id)
const scrollId = `performer-card-${id}`
if (sidebarOpen) { if (sidebarOpen) {
const el = document.getElementById(`performer-card-${id}`) const el = document.getElementById(scrollId)
if (el) { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} else { } else {
setSidebarOpen(true) setSidebarOpen(true)
setTimeout(() => { setTimeout(() => {
const el = document.getElementById(`performer-card-${id}`) const el = document.getElementById(scrollId)
if (el) { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 300) }, 300)
} }
}, [sidebarOpen]) }, [sidebarOpen])
@@ -145,11 +148,12 @@ export default function SearchPage() {
}, []) }, [])
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
let items: { type: 'offer' | 'performer', performer: SearchResultItem, offer?: any, distance: number, price: number }[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any
const items: { type: 'offer' | 'performer', performer: SearchResultItem, offer?: any, distance: number, price: number }[] = [];
performers.forEach(p => { performers.forEach(p => {
if (p.matchedOffers && p.matchedOffers.length > 0) { if (p.matchedOffers && p.matchedOffers.length > 0) {
p.matchedOffers.forEach(o => { p.matchedOffers.forEach(o => {
items.push({ type: 'offer', performer: p, offer: o, distance: p.distance || 0, price: o.priceAmount ?? o.amount ?? 0 }); items.push({ type: 'offer', performer: p, offer: o, distance: p.distance || 0, price: o.priceAmount ?? 0 });
}); });
} else { } else {
items.push({ type: 'performer', performer: p, distance: p.distance || 0, price: 0 }); items.push({ type: 'performer', performer: p, distance: p.distance || 0, price: 0 });
@@ -168,14 +172,12 @@ export default function SearchPage() {
return ( return (
<div className="flex w-full" style={{ height: "calc(100vh - 64px)" }}> <div className="flex w-full" style={{ height: "calc(100vh - 64px)" }}>
{/* === Боковая панель: Поиск + Список мастеров === */}
{sidebarOpen && ( {sidebarOpen && (
<aside className="w-2/5 min-w-[380px] max-w-[600px] border-r bg-background flex flex-col shrink-0 overflow-hidden shadow-xl z-10 relative"> <aside className="w-2/5 min-w-[380px] max-w-[600px] border-r bg-background flex flex-col shrink-0 overflow-hidden shadow-xl z-10 relative">
{/* Заголовок и фильтры */}
<div className="p-4 border-b space-y-4"> <div className="p-4 border-b space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold flex items-center gap-2"> <h1 className="text-xl font-bold flex items-center gap-2">
<Search className="h-5 w-5 text-primary" /> <SearchIcon className="h-5 w-5 text-primary" />
Поиск услуг Поиск услуг
</h1> </h1>
<button <button
@@ -186,9 +188,8 @@ export default function SearchPage() {
</button> </button>
</div> </div>
{/* Строка поиска */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> <SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Что вы ищете? Напр. 'бар', 'ремонт'" placeholder="Что вы ищете? Напр. 'бар', 'ремонт'"
className="pl-9 bg-muted/50 focus-visible:ring-primary" className="pl-9 bg-muted/50 focus-visible:ring-primary"
@@ -200,7 +201,6 @@ export default function SearchPage() {
/> />
</div> </div>
{/* Чекбоксы зон поиска */}
<div className="flex flex-col gap-2 pt-2"> <div className="flex flex-col gap-2 pt-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Искать в:</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Искать в:</p>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
@@ -238,7 +238,6 @@ export default function SearchPage() {
</div> </div>
</div> </div>
{/* Список найденных мастеров */}
<div className="flex-1 overflow-y-auto bg-muted/10"> <div className="flex-1 overflow-y-auto bg-muted/10">
{query.trim() === "" ? ( {query.trim() === "" ? (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3"> <div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
@@ -247,7 +246,7 @@ export default function SearchPage() {
</div> </div>
<p className="text-sm font-medium">Введите запрос для поиска</p> <p className="text-sm font-medium">Введите запрос для поиска</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Укажите название услуги, например 'уборка' или 'няня'. Укажите название услуги, например &apos;уборка&apos; или &apos;няня&apos;.
</p> </p>
</div> </div>
) : isLoading && performers.length === 0 ? ( ) : isLoading && performers.length === 0 ? (
@@ -258,11 +257,11 @@ export default function SearchPage() {
) : performers.length === 0 ? ( ) : performers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3"> <div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center"> <div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Search className="h-8 w-8 text-muted-foreground" /> <SearchIcon className="h-8 w-8 text-muted-foreground" />
</div> </div>
<p className="text-sm font-medium">Ничего не найдено</p> <p className="text-sm font-medium">Ничего не найдено</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Попробуйте изменить запрос, расширить зону поиска или убрать фильтры. Мы не нашли исполнителей непосредственно в &quot;{query}&quot;, но подобрали лучших мастеров поблизости.
</p> </p>
</div> </div>
) : ( ) : (
@@ -287,7 +286,8 @@ export default function SearchPage() {
{sortedItems.map((item, idx) => { {sortedItems.map((item, idx) => {
const p = item.performer const p = item.performer
const isOnline = p.status === "Готов к заказу" const isOnline = p.status === "Готов к заказу"
const key = item.type === 'offer' ? `${item.offer.id}-${idx}` : `${p.performerId}-${idx}` const cardId = p.performerId
const key = item.type === 'offer' ? `${item.offer.id}-${idx}` : `${cardId}-${idx}`
const formatDistance = (d?: number) => { const formatDistance = (d?: number) => {
if (d === undefined || d === null) return 'В вашем районе'; if (d === undefined || d === null) return 'В вашем районе';
@@ -302,11 +302,13 @@ export default function SearchPage() {
return ( return (
<div <div
key={key} key={key}
id={`performer-card-${p.performerId}-${idx}`} id={`performer-card-${cardId}`}
onClick={() => handleListPerformerClick(p.performerId)} onClick={() => handleListPerformerClick(cardId)}
className={`px-4 py-4 cursor-pointer transition-colors group ${activePerformerId === p.performerId ? 'bg-primary/5 shadow-inner border-l-4 border-l-primary' : 'hover:bg-muted/50 border-l-4 border-l-transparent'}`} className={cn(
"px-4 py-4 cursor-pointer transition-colors group",
activePerformerId === cardId ? "bg-primary/5 shadow-inner border-l-4 border-l-primary" : "hover:bg-muted/50 border-l-4 border-l-transparent"
)}
> >
{/* Информация об услуге или компетенции (главная) */}
{item.type === 'offer' ? ( {item.type === 'offer' ? (
<div className="mb-4 flex gap-4 items-stretch"> <div className="mb-4 flex gap-4 items-stretch">
{item.offer.imageUrl && ( {item.offer.imageUrl && (
@@ -321,7 +323,33 @@ export default function SearchPage() {
)} )}
<div className="flex-1 min-w-0 flex flex-col"> <div className="flex-1 min-w-0 flex flex-col">
<div className="flex justify-between items-start gap-2 mb-2"> <div className="flex justify-between items-start gap-2 mb-2">
<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-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"> <div className="flex flex-col items-end gap-1 shrink-0">
<span className="text-sm font-bold text-primary whitespace-nowrap bg-primary/10 px-2 py-0.5 rounded-md shadow-sm"> <span className="text-sm font-bold text-primary whitespace-nowrap bg-primary/10 px-2 py-0.5 rounded-md shadow-sm">
{item.price > 0 ? formatPrice(item.price) : "Цена не указана"} {item.price > 0 ? formatPrice(item.price) : "Цена не указана"}
@@ -342,7 +370,7 @@ export default function SearchPage() {
href={`/offers/${item.offer.id}`} href={`/offers/${item.offer.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent clicking the card onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input shadow-sm bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2" className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input shadow-sm bg-background hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2"
> >
Просмотреть услугу Просмотреть услугу
@@ -353,7 +381,7 @@ export default function SearchPage() {
) : ( ) : (
<div className="mb-1"> <div className="mb-1">
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3"> <h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Найдено по компетенции Найдено {sortedItems.length} предложений&apos; по запросу &ldquo;{query}&rdquo;
</h4> </h4>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{p.matchedCompetencies.map(c => ( {p.matchedCompetencies.map(c => (
@@ -365,7 +393,6 @@ export default function SearchPage() {
</div> </div>
)} )}
{/* Карточка исполнителя */}
<div className={performerClasses}> <div className={performerClasses}>
<div className="relative h-8 w-8 shrink-0 rounded-full overflow-hidden bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white"> <div className="relative h-8 w-8 shrink-0 rounded-full overflow-hidden bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
@@ -380,11 +407,33 @@ export default function SearchPage() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-medium text-xs truncate">{p.name}</p> <p className="font-medium text-xs truncate flex items-center gap-1">
<span className={`flex items-center gap-1 text-[10px] font-medium shrink-0 flex-nowrap ${isOnline ? "text-green-500" : "text-gray-400"}`}> <span title="Личность подтверждена">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${isOnline ? "bg-green-500 animate-pulse" : "bg-gray-400"}`} /> <SearchCheck className="w-3.5 h-3.5 text-green-500 shrink-0" />
{p.status} </span>
</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>
<div className="flex items-center justify-between mt-1"> <div className="flex items-center justify-between mt-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -406,39 +455,62 @@ export default function SearchPage() {
</aside> </aside>
)} )}
{!sidebarOpen && ( <button
<button onClick={() => setSidebarOpen(true)}
onClick={() => setSidebarOpen(true)} className={cn(
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" "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"
<Filter className="h-4 w-4 text-primary" /> )}
Фильтры и список >
</button> <SearchCheck className="h-5 w-5" />
)} </button>
{/* === Карта === */} <main className="flex-1 relative">
<div className="flex-1 relative z-0"> <LiveMap
{isLocationLoaded ? ( initialCenter={initialCenter}
<LiveMap performers={performers}
performers={performers} isLoading={isLoading}
isLoading={isLoading} error={error}
error={error} onBoundsChange={handleBoundsChange}
initialCenter={initialCenter} onUserLocated={handleUserLocated}
onBoundsChange={handleBoundsChange} activePerformerId={activePerformerId}
onUserLocated={handleUserLocated} centerTrigger={centerTrigger}
activePerformerId={activePerformerId} onPerformerClick={handlePerformerClick}
centerTrigger={centerTrigger} />
onPerformerClick={handlePerformerClick}
/> <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 justify-center w-full h-full bg-muted/30"> <div className="flex items-center gap-2 px-2 py-1">
<div className="flex flex-col items-center gap-4"> <Hammer className="h-4 w-4 text-primary" />
<div className="h-10 w-10 border-4 border-primary border-t-transparent rounded-full animate-spin" /> <span className="text-xs font-bold uppercase tracking-wider">Легенда</span>
<p className="text-muted-foreground text-sm">Определение вашей позиции...</p> </div>
<div className="flex items-center gap-3 p-2 hover:bg-muted/50 rounded transition-colors cursor-default">
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-medium">Готов к заказу</span>
</div>
<div className="flex items-center gap-3 p-2 hover:bg-muted/50 rounded transition-colors cursor-default">
<div className="h-3 w-3 rounded-full bg-blue-500" />
<span className="text-xs font-medium text-muted-foreground">На заказе</span>
</div>
<div className="flex items-center gap-3 p-2 hover:bg-muted/50 rounded transition-colors cursor-default">
<div className="h-3 w-3 rounded-full bg-slate-400" />
<span className="text-xs font-medium text-muted-foreground">Офлайн</span>
</div> </div>
</div> </div>
)}
</div> <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> </div>
) )
} }
-633
View File
@@ -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>
)
}
+46
View File
@@ -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
View File
@@ -31,10 +31,7 @@ export default function RootLayout({
> >
<QueryProvider> <QueryProvider>
<AuthProvider> <AuthProvider>
<div className="relative flex min-h-screen flex-col"> {children}
<Header />
<main className="flex-1">{children}</main>
</div>
<Toaster /> <Toaster />
</AuthProvider> </AuthProvider>
</QueryProvider> </QueryProvider>
+23
View File
@@ -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>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import Link from "next/link"
import { useSessionStore } from "@/entities/session/store" import { useSessionStore } from "@/entities/session/store"
export function PerformerButton() { export function PerformerButton() {
const { isAuth, user } = useSessionStore() const { user } = useSessionStore()
// Проверяем, является ли пользователь исполнителем или компанией // Проверяем, является ли пользователь исполнителем или компанией
const isMasterOrCompany = user?.roles?.includes("Master") || user?.roles?.includes("Company") const isMasterOrCompany = user?.roles?.includes("Master") || user?.roles?.includes("Company")
+2
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { useSessionStore } from "@/entities/session/store" import { useSessionStore } from "@/entities/session/store"
import { useRouter, usePathname } from "next/navigation" import { useRouter, usePathname } from "next/navigation"
import { useEffect } from "react" import { useEffect } from "react"
import { RatingStats } from "@/components/reputation/RatingStats"
export function Header() { export function Header() {
const { user, isAuth, getProfile } = useSessionStore() const { user, isAuth, getProfile } = useSessionStore()
@@ -77,6 +78,7 @@ export function Header() {
Подобрать заказ Подобрать заказ
</Button> </Button>
)} )}
<RatingStats userId={user!.id} size={16} className="hidden sm:flex" />
<Button variant="ghost" onClick={() => router.push('/dashboard/profile')}> <Button variant="ghost" onClick={() => router.push('/dashboard/profile')}>
{user?.firstName || 'Профиль'} {user?.firstName || 'Профиль'}
</Button> </Button>
+11 -3
View File
@@ -3,9 +3,11 @@
import { useEffect, useState, useCallback, useRef } from "react" import { useEffect, useState, useCallback, useRef } from "react"
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet" import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet"
import L from "leaflet" import L from "leaflet"
import { ShieldCheck } from "lucide-react"
import { SearchResultItem } from "@/shared/api/search" import { SearchResultItem } from "@/shared/api/search"
import { Offer } from "@/shared/api/catalog" import { Offer } from "@/shared/api/catalog"
import { CreateOrderModal } from "@/components/orders/CreateOrderModal" import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
import { RatingStats } from "@/components/reputation/RatingStats"
// Фикс иконок Leaflet для webpack/Next.js // Фикс иконок Leaflet для webpack/Next.js
const defaultIcon = L.icon({ const defaultIcon = L.icon({
@@ -189,8 +191,8 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
title: o.title, title: o.title,
description: o.description ?? "", description: o.description ?? "",
price: { price: {
amount: o.priceAmount ?? o.amount ?? 0, amount: o.priceAmount ?? 0,
currency: o.priceCurrency ?? o.currency ?? "RUB", currency: o.priceCurrency ?? "RUB",
type: 0, type: 0,
}, },
attributes: null, attributes: null,
@@ -259,10 +261,16 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
<Popup> <Popup>
<div className="text-sm min-w-[180px] space-y-2"> <div className="text-sm min-w-[180px] space-y-2">
<div> <div>
<p className="font-semibold">{p.name}</p> <p className="font-semibold flex items-center gap-1.5">
<span title="Личность подтверждена">
<ShieldCheck className="w-3.5 h-3.5 text-green-500 shrink-0" />
</span>
{p.name}
</p>
<p className={`text-xs font-medium mt-0.5 ${isOnline ? "text-green-600" : "text-gray-500"}`}> <p className={`text-xs font-medium mt-0.5 ${isOnline ? "text-green-600" : "text-gray-500"}`}>
{p.status} {p.status}
</p> </p>
<RatingStats userId={p.performerId} size={14} className="mt-1" />
</div> </div>
{p.matchedOffers.length > 0 && ( {p.matchedOffers.length > 0 && (
+8 -6
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Loader2, ShoppingBag, MapPin, Clock, ChevronDown } from "lucide-react" import { Loader2, ShoppingBag, MapPin, Clock } from "lucide-react"
import { createOrder, CreateOrderPayload } from "@/shared/api/orders" import { createOrder, CreateOrderPayload } from "@/shared/api/orders"
import { Offer } from "@/shared/api/catalog" import { Offer } from "@/shared/api/catalog"
import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice" import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice"
@@ -26,7 +26,7 @@ interface CreateOrderModalProps {
/** Имя мастера (для отображения в заказе) */ /** Имя мастера (для отображения в заказе) */
performerName?: string performerName?: string
/** Список услуг этого мастера (для выбора) */ /** Список услуг этого мастера (для выбора) */
offers: (Offer | any)[] offers: Offer[]
/** Предустановка конкретной услуги */ /** Предустановка конкретной услуги */
preselectedOfferId?: string preselectedOfferId?: string
userLocation?: { lat: number; lon: number; address?: string } userLocation?: { lat: number; lon: number; address?: string }
@@ -63,7 +63,7 @@ export function CreateOrderModal({
} else if (offers.length > 0 && !selectedOfferId) { } else if (offers.length > 0 && !selectedOfferId) {
setSelectedOfferId(offers[0].id) setSelectedOfferId(offers[0].id)
} }
}, [preselectedOfferId, offers]) }, [preselectedOfferId, offers, selectedOfferId])
const selectedOffer = offers.find((o) => o.id === selectedOfferId) const selectedOffer = offers.find((o) => o.id === selectedOfferId)
@@ -95,12 +95,14 @@ export function CreateOrderModal({
performerName: performerName, performerName: performerName,
} }
const orderId = await createOrder(payload) await createOrder(payload)
toast.success("Заказ успешно оформлен! Мастер получит уведомление.") toast.success("Заказ успешно оформлен! Мастер получит уведомление.")
onOpenChange(false) onOpenChange(false)
} catch (err: any) { } catch (err: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = err as any
const message = const message =
err?.response?.data?.detail ?? err?.response?.data ?? "Ошибка при оформлении заказа" error?.response?.data?.detail ?? error?.response?.data ?? "Ошибка при оформлении заказа"
toast.error(String(message)) toast.error(String(message))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -0,0 +1,332 @@
'use client';
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { toast } from 'sonner';
import { ImageUploader } from "@/components/ui/image-uploader";
import {
ShieldAlert,
Gavel,
User,
Clock,
Check,
FileText,
AlertCircle
} from 'lucide-react';
import {
Order,
respondToDispute,
rebutDispute,
acceptDisputeTerms
} from '@/shared/api/orders';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
interface DisputeDetailsModalProps {
isOpen: boolean;
onClose: () => void;
order: Order;
userId: string;
}
const DISPUTE_REASONS_MAP: Record<string, string> = {
"WorkNotPerformed": "Работы не выполнены",
"NotFullVolume": "Выполнены не в полном объеме",
"PoorQuality": "Выполнены некачественно",
"DeadlineViolated": "Нарушен срок",
"PriceIncreased": "Увеличилась стоимость",
};
export const DisputeDetailsModal = ({ isOpen, onClose, order, userId }: DisputeDetailsModalProps) => {
const [actionComment, setActionComment] = useState("");
const [actionCounterSolution, setActionCounterSolution] = useState("");
const [actionEvidence, setActionEvidence] = useState<string[]>([]);
const [showReplyForm, setShowReplyForm] = useState(false);
const qc = useQueryClient();
const isCustomer = order.customerId === userId;
const isPerformer = order.performerId === userId;
const dispute = order.dispute;
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["orders"] });
};
const mutateRespond = useMutation({
mutationFn: () => respondToDispute(order.id, {
performerId: userId,
position: actionComment,
evidence: actionEvidence,
counterSolution: actionCounterSolution || undefined
}),
onSuccess: () => {
toast.success("Ваш ответ отправлен заказчику.");
invalidate();
onClose();
resetForm();
},
onError: (err: unknown) => {
const error = err as any;
toast.error(error?.response?.data?.detail ?? "Ошибка при отправке ответа")
}
});
const mutateRebut = useMutation({
mutationFn: () => rebutDispute(order.id, {
customerId: userId,
comment: actionComment,
evidence: actionEvidence,
counterSolution: actionCounterSolution || undefined
}),
onSuccess: () => {
toast.success("Ваши новые требования отправлены исполнителю.");
invalidate();
onClose();
resetForm();
},
onError: (err: unknown) => {
const error = err as any;
toast.error(error?.response?.data?.detail ?? "Ошибка при отправке возражения")
}
});
const mutateAccept = useMutation({
mutationFn: () => acceptDisputeTerms(order.id, userId),
onSuccess: () => {
toast.success("Вы приняли условия. Спор закрыт, заказ завершен.");
invalidate();
onClose();
},
onError: (err: unknown) => {
const error = err as any;
toast.error(error?.response?.data?.detail ?? "Ошибка при принятии условий")
}
});
const resetForm = () => {
setActionComment("");
setActionCounterSolution("");
setActionEvidence([]);
setShowReplyForm(false);
};
if (!dispute) return null;
const canRespond = isPerformer && dispute.status === "WaitingForPerformer";
const canRebut = isCustomer && dispute.status === "WaitingForCustomer";
const canAccept = isCustomer && dispute.status === "WaitingForCustomer";
const getAuthorLabel = (authorId: string) => {
if (authorId === order.customerId) return "Заказчик (" + order.customerName + ")";
if (authorId === order.performerId) return "Исполнитель (" + order.performerName + ")";
return "Система";
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[700px] border-none p-0 overflow-hidden bg-white dark:bg-zinc-900 shadow-2xl rounded-[32px]">
<div className="bg-rose-500/10 p-6 flex items-center gap-4 border-b border-rose-500/10">
<div className="h-12 w-12 rounded-2xl bg-rose-500 flex items-center justify-center shadow-lg shadow-rose-500/20">
<Gavel className="h-6 w-6 text-white" />
</div>
<div className="flex-1">
<DialogTitle className="text-xl font-bold text-rose-600 dark:text-rose-400">
Детали спора
</DialogTitle>
<div className="flex items-center gap-2">
<p className="text-xs text-rose-600/60 font-medium uppercase tracking-wider">Заказ #{order.id.slice(0, 8)}</p>
<span className="w-1 h-1 rounded-full bg-rose-300 mx-1" />
<p className="text-xs font-bold text-rose-500">{DISPUTE_REASONS_MAP[dispute.reason] || dispute.reason}</p>
</div>
</div>
<div className={cn(
"px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-wider border",
dispute.status === "WaitingForPerformer" ? "bg-amber-100 text-amber-700 border-amber-200" :
dispute.status === "WaitingForCustomer" ? "bg-blue-100 text-blue-700 border-blue-200" :
"bg-green-100 text-green-700 border-green-200"
)}>
{dispute.status === "WaitingForPerformer" ? "Ожидает исполнителя" :
dispute.status === "WaitingForCustomer" ? "Ожидает заказчика" :
"Решено"}
</div>
</div>
<div className="p-0 flex flex-col max-h-[75vh]">
<div className="p-8 space-y-8 overflow-y-auto custom-scrollbar flex-1">
{/* История сообщений */}
<div className="space-y-6">
<h3 className="text-sm font-black uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
История обсуждения
</h3>
<div className="relative space-y-8 before:absolute before:inset-0 before:ml-5 before:-translate-x-px before:h-full before:w-0.5 before:bg-gradient-to-b before:from-zinc-100 before:via-zinc-200 before:to-transparent dark:before:from-zinc-800 dark:before:via-zinc-700">
{dispute.messages.map((msg) => (
<div key={msg.id} className="relative flex items-start group">
<div className={cn(
"flex items-center justify-center h-10 w-10 rounded-2xl border-4 border-white dark:border-zinc-900 shadow-sm shrink-0 z-10",
msg.authorId === order.customerId ? "bg-blue-500 text-white" : "bg-indigo-500 text-white"
)}>
{msg.authorId === order.customerId ? <User className="h-5 w-5" /> : <ShieldAlert className="h-5 w-5" />}
</div>
<div className="flex-1 ml-4 pt-1">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-black uppercase tracking-wider text-muted-foreground/80">
{getAuthorLabel(msg.authorId)}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
{new Date(msg.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<div className="bg-zinc-50 dark:bg-zinc-950 rounded-2xl p-4 border border-zinc-100 dark:border-zinc-800 shadow-sm group-hover:shadow-md transition-shadow">
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{msg.text}
</p>
{msg.proposedSolution && (
<div className="mt-3 pt-3 border-t border-zinc-100 dark:border-zinc-800">
<p className="text-[10px] font-black uppercase text-rose-500/60 mb-1 tracking-wider">Предлагаемое решение</p>
<p className="text-xs font-bold text-rose-600 dark:text-rose-400 italic">
«{msg.proposedSolution}»
</p>
</div>
)}
{msg.evidence && msg.evidence.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{msg.evidence.map((url, i) => (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
className="h-16 w-16 rounded-xl overflow-hidden border border-zinc-200 dark:border-zinc-800 hover:ring-2 ring-primary transition-all"
>
<img src={url} alt="Evidence" className="h-full w-full object-cover" />
</a>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Форма ответа */}
{showReplyForm ? (
<div className="animate-in slide-in-from-bottom-4 duration-300 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<div className="bg-zinc-50 dark:bg-zinc-950 rounded-[32px] p-6 space-y-6 border border-zinc-200 dark:border-zinc-800 shadow-lg">
<div className="flex items-center justify-between px-2">
<h3 className="text-sm font-black uppercase tracking-widest text-primary">Ваш ответ</h3>
<Button variant="ghost" size="sm" onClick={() => setShowReplyForm(false)} className="rounded-full h-8 text-xs">
Отмена
</Button>
</div>
<div className="space-y-4">
<div className="space-y-2 text-xs">
<label className="font-bold text-zinc-700 dark:text-zinc-300 ml-2">Комментарий / Позиция</label>
<Textarea
placeholder="Опишите ваши аргументы и факты..."
value={actionComment}
onChange={(e) => setActionComment(e.target.value)}
className="min-h-[100px] rounded-2xl bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 resize-none p-4"
/>
</div>
<div className="space-y-2 text-xs">
<label className="font-bold text-zinc-700 dark:text-zinc-300 ml-2">Ваше предложение по решению</label>
<Textarea
placeholder="Например: 'Согласен на возврат 50%' или 'Готов доделать завтра'"
value={actionCounterSolution}
onChange={(e) => setActionCounterSolution(e.target.value)}
className="min-h-[80px] rounded-2xl bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 resize-none p-4"
/>
</div>
<div className="space-y-3">
<label className="text-[11px] font-bold text-zinc-700 dark:text-zinc-300 ml-2 uppercase tracking-wider">Материалы и доказательства</label>
<div className="p-4 rounded-3xl bg-white dark:bg-zinc-900 border border-dashed border-zinc-300 dark:border-zinc-700">
<ImageUploader
value={actionEvidence}
onChange={setActionEvidence}
/>
</div>
</div>
</div>
<Button
className="w-full h-12 rounded-2xl bg-primary hover:bg-primary/90 text-white font-bold shadow-lg shadow-primary/20"
onClick={() => {
if (canRespond) mutateRespond.mutate();
else if (canRebut) mutateRebut.mutate();
}}
disabled={mutateRespond.isPending || mutateRebut.isPending || !actionComment}
>
{(mutateRespond.isPending || mutateRebut.isPending) ? "Отправка..." : "Отправить ответ"}
</Button>
</div>
</div>
) : (
<div className="p-4 rounded-2xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
<div className="flex gap-3">
<AlertCircle className="h-5 w-5 text-amber-500 shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-400 font-medium leading-relaxed">
{dispute.status === "WaitingForPerformer" && !isPerformer ? "Ожидаем ответ исполнителя. У него есть время на подготовку материалов." :
dispute.status === "WaitingForCustomer" && !isCustomer ? "Ожидаем решение заказчика." :
"Вы можете добавить материалы или принять условия, чтобы закрыть спор."}
</p>
</div>
</div>
)}
</div>
{!showReplyForm && (
<div className="p-8 border-t border-zinc-100 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-950/20 flex flex-col sm:flex-row gap-4">
{(canRespond || canRebut) && (
<Button
onClick={() => setShowReplyForm(true)}
className="flex-1 h-14 rounded-2xl bg-indigo-600 hover:bg-indigo-700 text-white font-bold shadow-xl shadow-indigo-600/20"
>
<FileText className="h-5 w-5 mr-2" />
{canRespond ? "Ответить на претензию" : "Возразить / Добавить материалы"}
</Button>
)}
{canAccept && (
<Button
onClick={() => {
if (window.confirm("Вы уверены, что хотите принять условия исполнителя и закрыть спор? Заказ будет завершен.")) {
mutateAccept.mutate();
}
}}
variant="outline"
className="flex-1 h-14 rounded-2xl border-green-500/20 text-green-600 hover:bg-green-600 hover:text-white transition-all font-bold shadow-xl shadow-green-600/5"
>
<Check className="h-5 w-5 mr-2" />
Принять условия и закрыть спор
</Button>
)}
<Button
variant="ghost"
onClick={onClose}
className="h-14 rounded-2xl font-bold text-muted-foreground"
>
Закрыть
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
+205
View File
@@ -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>
);
};
+37
View File
@@ -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>
);
};
+101
View File
@@ -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>
);
};
+156
View File
@@ -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>
);
};
+49
View File
@@ -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>
);
};
+2 -2
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Upload, User, Check, X, Edit2 } from "lucide-react" import { User, Check, X, Edit2 } from "lucide-react"
import Cropper from "react-easy-crop" import Cropper from "react-easy-crop"
import { api } from "@/shared/api/axios" import { api } from "@/shared/api/axios"
import { toast } from "sonner" import { toast } from "sonner"
@@ -38,7 +38,7 @@ export function AvatarUploader({ currentAvatarUrl, onUploadSuccess }: AvatarUplo
setPreview(currentAvatarUrl) setPreview(currentAvatarUrl)
}, [currentAvatarUrl]) }, [currentAvatarUrl])
const onCropComplete = React.useCallback((_: any, croppedAreaPixels: CropArea) => { const onCropComplete = React.useCallback((_: unknown, croppedAreaPixels: CropArea) => {
setCroppedAreaPixels(croppedAreaPixels) setCroppedAreaPixels(croppedAreaPixels)
}, []) }, [])
+31 -24
View File
@@ -1,29 +1,36 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/shared/lib/utils" import { cva, type VariantProps } from "class-variance-authority"
const Badge = React.forwardRef< import { cn } from "@/lib/utils"
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "secondary" | "destructive" | "outline" } const badgeVariants = cva(
>(({ className, variant = "default", ...props }, ref) => { "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
const variants = { {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", variants: {
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", variant: {
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground", outline: "text-foreground",
} },
},
defaultVariants: {
variant: "default",
},
}
)
return ( export interface BadgeProps
<div extends React.HTMLAttributes<HTMLDivElement>,
ref={ref} VariantProps<typeof badgeVariants> {}
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 } function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+40 -39
View File
@@ -2,54 +2,55 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
destructive: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive:
outline: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", outline:
secondary: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary:
ghost: "hover:bg-accent hover:text-accent-foreground", "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
link: "text-primary underline-offset-4 hover:underline", ghost: "hover:bg-accent hover:text-accent-foreground",
}, link: "text-primary underline-offset-4 hover:underline",
size: { },
default: "h-10 px-4 py-2", size: {
sm: "h-9 rounded-md px-3", default: "h-9 px-4 py-2",
lg: "h-11 rounded-md px-8", sm: "h-8 rounded-md px-3 text-xs",
icon: "h-10 w-10", lg: "h-10 rounded-md px-8",
}, icon: "h-9 w-9",
}, },
defaultVariants: { },
variant: "default", defaultVariants: {
size: "default", variant: "default",
}, size: "default",
} },
}
) )
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) )
} }
) )
Button.displayName = "Button" Button.displayName = "Button"
+43 -45
View File
@@ -1,77 +1,75 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/shared/lib/utils"
import { cn } from "@/lib/utils"
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-xl border bg-card text-card-foreground shadow",
className className
)} )}
{...props} {...props}
/> />
)) ))
Card.displayName = "Card" Card.displayName = "Card"
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) ))
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLDivElement,
React.HTMLAttributes<HTMLHeadingElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h3 <div
ref={ref} ref={ref}
className={cn( className={cn("font-semibold leading-none tracking-tight", className)}
"text-2xl font-semibold leading-none tracking-tight", {...props}
className />
)}
{...props}
/>
)) ))
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLDivElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <div
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ))
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ))
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) ))
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter"
+4 -2
View File
@@ -57,8 +57,10 @@ export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalP
toast.success("Пароль успешно изменён") toast.success("Пароль успешно изменён")
reset() reset()
onOpenChange(false) onOpenChange(false)
} catch (error: any) { } catch (error: unknown) {
toast.error(error?.response?.data?.message || "Ошибка при смене пароля") // eslint-disable-next-line @typescript-eslint/no-explicit-any
const err = error as any
toast.error(err?.response?.data?.message || "Ошибка при смене пароля")
} }
} }
+16 -16
View File
@@ -4,26 +4,26 @@ import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react" import { Check } from "lucide-react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", "grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className className
)} )}
{...props} {...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
> >
<CheckboxPrimitive.Indicator <Check className="h-4 w-4" />
className={cn("flex items-center justify-center text-current")} </CheckboxPrimitive.Indicator>
> </CheckboxPrimitive.Root>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)) ))
Checkbox.displayName = CheckboxPrimitive.Root.displayName Checkbox.displayName = CheckboxPrimitive.Root.displayName
+117 -132
View File
@@ -1,137 +1,122 @@
'use client' "use client"
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react" import { X } from "lucide-react"
import { cn } from "@/shared/lib/utils"
interface DialogProps { import { cn } from "@/lib/utils"
open?: boolean
onOpenChange?: (open: boolean) => void const Dialog = DialogPrimitive.Root
children: React.ReactNode
} const DialogTrigger = DialogPrimitive.Trigger
export function Dialog({ open, onOpenChange, children }: DialogProps) { const DialogPortal = DialogPrimitive.Portal
if (!open) return null
const DialogClose = DialogPrimitive.Close
// Передаем onOpenChange через контекст или рендерим children с пропсами
return <>{children}</> const DialogOverlay = React.forwardRef<
} React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> { >(({ className, ...props }, ref) => (
children: React.ReactNode <DialogPrimitive.Overlay
onClose?: () => void ref={ref}
} className={cn(
"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",
export function DialogContent({ children, className, onClose, ...props }: DialogContentProps) { className
return ( )}
<div className="fixed inset-0 z-50 flex items-center justify-center"> {...props}
{/* Backdrop */} />
<div ))
className="absolute inset-0 bg-black/50 backdrop-blur-sm" DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
onClick={onClose}
/> const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
{/* Content */} React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
<div >(({ className, children, ...props }, ref) => (
className={cn( <DialogPortal>
"relative z-50 w-full max-w-lg mx-4 bg-white dark:bg-zinc-900 rounded-lg shadow-lg", <DialogOverlay />
className <DialogPrimitive.Content
)} ref={ref}
{...props} 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",
{/* Кнопка закрытия (X) */} className
{onClose && ( )}
<button {...props}
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} {children}
> <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" /> <X className="h-4 w-4" />
<span className="sr-only">Закрыть</span> <span className="sr-only">Close</span>
</button> </DialogPrimitive.Close>
)} </DialogPrimitive.Content>
{children} </DialogPortal>
</div> ))
</div> DialogContent.displayName = DialogPrimitive.Content.displayName
)
} const DialogHeader = ({
className,
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> { } ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
export function DialogHeader({ className, ...props }: DialogHeaderProps) { <div
return ( className={cn(
<div "flex flex-col space-y-1.5 text-center sm:text-left",
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} className
{...props} )}
/> {...props}
) />
} )
DialogHeader.displayName = "DialogHeader"
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> { }
const DialogFooter = ({
export function DialogFooter({ className, ...props }: DialogFooterProps) { className,
return ( ...props
<div }: React.HTMLAttributes<HTMLDivElement>) => (
className={cn( <div
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className={cn(
className "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
)} className
{...props} )}
/> {...props}
) />
} )
DialogFooter.displayName = "DialogFooter"
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> { }
const DialogTitle = React.forwardRef<
export function DialogTitle({ className, ...props }: DialogTitleProps) { React.ElementRef<typeof DialogPrimitive.Title>,
return ( React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
<h2 >(({ className, ...props }, ref) => (
className={cn("text-lg font-semibold leading-none tracking-tight", className)} <DialogPrimitive.Title
{...props} ref={ref}
/> className={cn(
) "text-lg font-semibold leading-none tracking-tight",
} className
)}
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> { } {...props}
/>
export function DialogDescription({ className, ...props }: DialogDescriptionProps) { ))
return ( DialogTitle.displayName = DialogPrimitive.Title.displayName
<p
className={cn("text-sm text-muted-foreground", className)} const DialogDescription = React.forwardRef<
{...props} React.ElementRef<typeof DialogPrimitive.Description>,
/> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
) >(({ className, ...props }, ref) => (
} <DialogPrimitive.Description
ref={ref}
interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { className={cn("text-sm text-muted-foreground", className)}
asChild?: boolean {...props}
} />
))
export function DialogClose({ className, asChild, ...props }: DialogCloseProps) { DialogDescription.displayName = DialogPrimitive.Description.displayName
// Если asChild=true, нужно использовать Slot из radix-ui
if (asChild) { export {
// eslint-disable-next-line @typescript-eslint/no-require-imports Dialog,
const { Slot } = require("@radix-ui/react-slot") as { Slot: React.ComponentType<any> } DialogPortal,
return ( DialogOverlay,
<Slot DialogTrigger,
className={cn( DialogClose,
"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", DialogContent,
className DialogHeader,
)} DialogFooter,
{...props} DialogTitle,
/> DialogDescription,
)
}
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>
)
} }
+135 -134
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react" import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root
@@ -19,182 +19,183 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8", inset && "pl-8",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ))
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className className
)} )}
{...props} {...props}
/> />
)) ))
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => ( >(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
className "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} )}
/> {...props}
</DropdownMenuPrimitive.Portal> />
</DropdownMenuPrimitive.Portal>
)) ))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8", inset && "pl-8",
className className
)} )}
{...props} {...props}
/> />
)) ))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => ( >(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
checked={checked} checked={checked}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ))
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8", inset && "pl-8",
className className
)} )}
{...props} {...props}
/> />
)) ))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) )
} }
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuPortal, DropdownMenuPortal,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} }
+113 -112
View File
@@ -1,177 +1,178 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { import {
Controller, Controller,
ControllerProps, FormProvider,
FieldPath, useFormContext,
FieldValues, type ControllerProps,
FormProvider, type FieldPath,
useFormContext, type FieldValues,
} from "react-hook-form" } from "react-hook-form"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
const Form = FormProvider const Form = FormProvider
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = { > = {
name: TName name: TName
} }
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
{} as FormFieldContextValue
)
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
return ( return (
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) )
} }
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext() const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState) if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
if (!fieldContext) { if (!itemContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormItem>")
} }
const { id } = itemContext const fieldState = getFieldState(fieldContext.name, formState)
return { const { id } = itemContext
id,
name: fieldContext.name, return {
formItemId: `${id}-form-item`, id,
formDescriptionId: `${id}-form-item-description`, name: fieldContext.name,
formMessageId: `${id}-form-item-message`, formItemId: `${id}-form-item`,
...fieldState, formDescriptionId: `${id}-form-item-description`,
} formMessageId: `${id}-form-item-message`,
...fieldState,
}
} }
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string
} }
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue | null>(null)
{} as FormItemContextValue
)
const FormItem = React.forwardRef< const FormItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const id = React.useId() const id = React.useId()
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
) )
}) })
FormItem.displayName = "FormItem" FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef< const FormLabel = React.forwardRef<
React.ElementRef<typeof Label>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof Label> React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField()
return ( return (
<Label <Label
ref={ref} ref={ref}
className={cn(error && "text-destructive", className)} className={cn(error && "text-destructive", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) )
}) })
FormLabel.displayName = "FormLabel" FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef< const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>, React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return ( return (
<Slot <Slot
ref={ref} ref={ref}
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
? `${formDescriptionId}` ? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}` : `${formDescriptionId} ${formMessageId}`
} }
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) )
}) })
FormControl.displayName = "FormControl" FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef< const FormDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField()
return ( return (
<p <p
ref={ref} ref={ref}
id={formDescriptionId} id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} {...props}
/> />
) )
}) })
FormDescription.displayName = "FormDescription" FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef< const FormMessage = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children const body = error ? String(error?.message ?? "") : children
if (!body) { if (!body) {
return null return null
} }
return ( return (
<p <p
ref={ref} ref={ref}
id={formMessageId} id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)} className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) )
}) })
FormMessage.displayName = "FormMessage" FormMessage.displayName = "FormMessage"
export { export {
useFormField, useFormField,
Form, Form,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl, FormControl,
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} }
+5 -5
View File
@@ -2,7 +2,7 @@ import React, { useRef, useState } from "react";
import { uploadOfferImage } from "@/shared/api/catalog"; import { uploadOfferImage } from "@/shared/api/catalog";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, ImagePlus, Loader2, ArrowLeft, ArrowRight } from "lucide-react"; import { X, ImagePlus, Loader2 } from "lucide-react";
interface ImageUploaderProps { interface ImageUploaderProps {
value: string[]; value: string[];
@@ -37,12 +37,12 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
newUrls.push(url); newUrls.push(url);
} }
onChange(newUrls); onChange(newUrls);
} catch (error) { } catch {
toast.error("Не удалось загрузить изображения"); toast.error("Не удалось загрузить изображения");
} finally { } finally {
setUploading(false); setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
} }
if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const removeImage = (index: number) => { const removeImage = (index: number) => {
@@ -57,7 +57,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
// To make drag image look clean, optionally set drag image here // To make drag image look clean, optionally set drag image here
}; };
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, index: number) => { const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = "move"; e.dataTransfer.dropEffect = "move";
}; };
@@ -87,7 +87,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
className={`flex flex-col gap-2 ${draggedIndex === i ? 'opacity-50' : 'opacity-100'}`} className={`flex flex-col gap-2 ${draggedIndex === i ? 'opacity-50' : 'opacity-100'}`}
draggable draggable
onDragStart={(e) => handleDragStart(e, i)} onDragStart={(e) => handleDragStart(e, i)}
onDragOver={(e) => handleDragOver(e, i)} onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e, i)} onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
+14 -14
View File
@@ -1,21 +1,21 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) )
} }
) )
Input.displayName = "Input" Input.displayName = "Input"
+11 -9
View File
@@ -1,23 +1,25 @@
"use client"
import * as React from "react" import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
) )
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} ref={ref}
className={cn(labelVariants(), className)} className={cn(labelVariants(), className)}
{...props} {...props}
/> />
)) ))
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName
+3 -2
View File
@@ -1,6 +1,5 @@
'use client' 'use client'
import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod" import { z } from "zod"
@@ -60,7 +59,9 @@ export function LocationModal({
await updateLocation(data.location || "", data.currentLocation || "") await updateLocation(data.location || "", data.currentLocation || "")
toast.success("Локация успешно обновлена") toast.success("Локация успешно обновлена")
onOpenChange(false) onOpenChange(false)
} catch (error: any) { } catch (err: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = err as any
toast.error(error?.response?.data?.message || "Ошибка при обновлении локации") toast.error(error?.response?.data?.message || "Ошибка при обновлении локации")
} }
} }
+3 -3
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react" import { Circle } from "lucide-react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef< const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>, React.ElementRef<typeof RadioGroupPrimitive.Root>,
@@ -28,13 +28,13 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator className="flex items-center justify-center"> <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" /> <Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) )
+26 -10
View File
@@ -3,18 +3,24 @@
import * as React from "react" import * as React from "react"
import { Star } from "lucide-react" import { Star } from "lucide-react"
import { getRatingColor } from "@/shared/lib/ratingColors"
interface RatingProps { interface RatingProps {
value: number value: number
showValue?: boolean showValue?: boolean
size?: "sm" | "md" | "lg" size?: "sm" | "md" | "lg"
showCount?: boolean
count?: number
} }
export function Rating({ value, showValue = true, size = "md" }: RatingProps) { export function Rating({ value, showValue = true, size = "md", showCount = false, count }: RatingProps) {
// Определяем цвет звезды на основе рейтинга // Определяем цвет звезды на основе рейтинга (3 цвета как в профиле)
const getStarColor = () => { const getStarStyle = () => getRatingColor(value);
if (value >= 4) return "text-green-600 dark:text-green-500"
if (value >= 3) return "text-blue-600 dark:text-blue-500" const hasNoRating = value === 0 || (showCount && count === 0);
return "text-red-600 dark:text-red-500"
if (hasNoRating) {
return <span className="text-sm text-muted-foreground italic font-medium">Без рейтинга</span>
} }
// Размеры иконки // Размеры иконки
@@ -26,7 +32,6 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
// Создаем массив из 5 звезд с процентом заполнения // Создаем массив из 5 звезд с процентом заполнения
const stars = Array.from({ length: 5 }, (_, index) => { const stars = Array.from({ length: 5 }, (_, index) => {
const starValue = index + 1
const fillPercentage = Math.max(0, Math.min(100, (value - index) * 100)) const fillPercentage = Math.max(0, Math.min(100, (value - index) * 100))
return { index, fillPercentage } return { index, fillPercentage }
@@ -48,7 +53,7 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
style={{ width: `${fillPercentage}%` }} style={{ width: `${fillPercentage}%` }}
> >
<Star <Star
className={`${sizeClasses[size]} ${getStarColor()} fill-current`} className={`${sizeClasses[size]} ${getStarStyle()}`}
/> />
</div> </div>
</div> </div>
@@ -56,11 +61,22 @@ export function Rating({ value, showValue = true, size = "md" }: RatingProps) {
</div> </div>
{/* Числовое значение справа */} {/* Числовое значение справа */}
{showValue && ( {showValue && value > 0 && (
<span className={`font-semibold ${getStarColor()}`}> <span className={`font-semibold ${getStarStyle().split(' ')[0]}`}>
{value.toFixed(2)} {value.toFixed(2)}
</span> </span>
)} )}
{value === 0 && (
<span className="text-sm text-muted-foreground italic">Без рейтинга</span>
)}
{/* Количество отзывов */}
{showCount && count !== undefined && count > 0 && (
<span className="text-sm text-muted-foreground">
({count} {count === 1 ? 'отзыв' : count >= 2 && count <= 4 ? 'отзыва' : 'отзывов'})
</span>
)}
</div> </div>
) )
} }
-22
View File
@@ -55,28 +55,6 @@ export function RichTextEditor({ content, onChange, placeholder, maxLength }: Ri
}, },
}) })
const handleLinkClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
const href = e.currentTarget.getAttribute("href")
if (!href) return
// Проверяем, является ли ссылка внешней
const isExternal = href.startsWith("http://") || href.startsWith("https://")
if (isExternal) {
const domain = new URL(href).hostname
const confirmed = confirm(
`Вы переходите на внешний ресурс: ${domain}\n\n` +
`Ссылка: ${href}\n\n` +
`Вы уверены, что хотите перейти?`
)
if (confirmed) {
window.open(href, "_blank", "noopener,noreferrer")
}
} else {
window.open(href, "_blank")
}
}, [])
const setLink = useCallback(() => { const setLink = useCallback(() => {
if (!editor) return if (!editor) return
+3 -1
View File
@@ -184,7 +184,9 @@ export function ScheduleModal({
await updateSchedule(isAlwaysReady, Object.keys(scheduleData).length > 0 ? scheduleData : undefined) await updateSchedule(isAlwaysReady, Object.keys(scheduleData).length > 0 ? scheduleData : undefined)
toast.success("Расписание успешно обновлено") toast.success("Расписание успешно обновлено")
onOpenChange(false) onOpenChange(false)
} catch (error: any) { } catch (err: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = err as any
toast.error(error?.response?.data?.message || "Ошибка при обновлении расписания") toast.error(error?.response?.data?.message || "Ошибка при обновлении расписания")
} }
} }
+108 -108
View File
@@ -13,147 +13,147 @@ const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)) ))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ))
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => ( >(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className
)} )}
position={position} position={position}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)} )}
> >
{children} {children}
</SelectPrimitive.Viewport> </SelectPrimitive.Viewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ))
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)} className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} {...props}
/> />
)) ))
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
> >
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ))
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} {...props}
/> />
)) ))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export { export {
Select, Select,
SelectGroup, SelectGroup,
SelectValue, SelectValue,
SelectTrigger, SelectTrigger,
SelectContent, SelectContent,
SelectLabel, SelectLabel,
SelectItem, SelectItem,
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} }
+17 -16
View File
@@ -2,25 +2,26 @@
import * as React from "react" import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/shared/lib/utils"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full touch-none select-none items-center", "relative flex w-full touch-none select-none items-center",
className className
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" /> <SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ))
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName
+19 -19
View File
@@ -6,26 +6,26 @@ import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner> type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toaster]:text-muted-foreground", description: "group-[.toast]:text-muted-foreground",
actionButton: actionButton:
"group-[.toaster]:bg-primary group-[.toaster]:text-primary-foreground", "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: cancelButton:
"group-[.toaster]:bg-muted group-[.toaster]:text-muted-foreground", "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
}, },
}} }}
{...props} {...props}
/> />
) )
} }
export { Toaster } export { Toaster }
+22 -39
View File
@@ -1,46 +1,29 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { cn } from "@/shared/lib/utils" import * as SwitchPrimitives from "@radix-ui/react-switch"
interface SwitchProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { import { cn } from "@/lib/utils"
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}
/** const Switch = React.forwardRef<
* Переключатель (тумблер) — аналог Shadcn Switch. React.ElementRef<typeof SwitchPrimitives.Root>,
* Используется для тумблера «На линии» в шапке. React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
*/ >(({ className, ...props }, ref) => (
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>( <SwitchPrimitives.Root
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => { className={cn(
return ( "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",
<button className
type="button" )}
role="switch" {...props}
aria-checked={checked} ref={ref}
disabled={disabled} >
ref={ref} <SwitchPrimitives.Thumb
onClick={() => onCheckedChange?.(!checked)} className={cn(
className={cn( "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"
"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", </SwitchPrimitives.Root>
checked ? "bg-green-500" : "bg-input", ))
className Switch.displayName = SwitchPrimitives.Root.displayName
)}
{...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 } export { Switch }
+16 -18
View File
@@ -1,24 +1,22 @@
import * as React from "react" import * as React from "react"
import { cn } from "@/shared/lib/utils" import { cn } from "@/lib/utils"
export interface TextareaProps const Textarea = React.forwardRef<
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} HTMLTextAreaElement,
React.ComponentProps<"textarea">
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( >(({ className, ...props }, ref) => {
({ className, ...props }, ref) => { return (
return ( <textarea
<textarea className={cn(
className={cn( "flex min-h-[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",
"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
className )}
)} ref={ref}
ref={ref} {...props}
{...props} />
/> )
) })
}
)
Textarea.displayName = "Textarea" Textarea.displayName = "Textarea"
export { Textarea } export { Textarea }
+8 -4
View File
@@ -16,6 +16,7 @@ interface User {
currentLocation?: string; currentLocation?: string;
fullName: string; fullName: string;
avatarUrl?: string; avatarUrl?: string;
status?: string;
competencies: string[]; competencies: string[];
workSchedule?: { workSchedule?: {
isAlwaysReady: boolean; isAlwaysReady: boolean;
@@ -35,7 +36,7 @@ interface SessionState {
isInitialized: boolean; isInitialized: boolean;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
login: (phone: string, password: string) => Promise<void>; login: (phone: string, password: string) => Promise<void>;
register: (data: any) => Promise<void>; register: (data: unknown) => Promise<void>;
logout: () => void; logout: () => void;
getProfile: () => Promise<void>; getProfile: () => Promise<void>;
updateProfile: (data: { firstName: string, lastName: string, patronymic?: string, companyName?: string, inn?: string, description?: string }) => Promise<void>; updateProfile: (data: { firstName: string, lastName: string, patronymic?: string, companyName?: string, inn?: string, description?: string }) => Promise<void>;
@@ -100,12 +101,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
} }
}, },
register: async (data: any) => { register: async (data: unknown) => {
set({ isLoading: true }); set({ isLoading: true });
try { try {
// Убираем confirmPassword перед отправкой на бэкенд // Убираем confirmPassword перед отправкой на бэкенд
const { confirmPassword, ...registerData } = data; // eslint-disable-next-line @typescript-eslint/no-explicit-any
await api.post("/auth/register", registerData); const registerData = { ...(data as Record<string, any>) };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { confirmPassword, ...payload } = registerData;
await api.post("/auth/register", payload);
} finally { } finally {
set({ isLoading: false }); set({ isLoading: false });
} }
+2 -1
View File
@@ -21,7 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { useSessionStore } from "@/entities/session/store" import { useSessionStore } from "@/entities/session/store"
type UserRole = "User" | "Candidate" | "Company" // type UserRole = "User" | "Candidate" | "Company"
const registerSchema = z.object({ const registerSchema = z.object({
role: z.enum(["User", "Candidate", "Company"]), role: z.enum(["User", "Candidate", "Company"]),
@@ -84,6 +84,7 @@ export function RegisterForm() {
// Формируем E.164 формат // Формируем E.164 формат
const formattedPhone = cleanPhone.startsWith('7') ? `+${cleanPhone}` : `+7${cleanPhone}`; const formattedPhone = cleanPhone.startsWith('7') ? `+${cleanPhone}` : `+7${cleanPhone}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const submitData: any = { const submitData: any = {
phone: formattedPhone, phone: formattedPhone,
password: values.password, password: values.password,
+34 -8
View File
@@ -1,6 +1,12 @@
import { create } from "zustand"; import { create } from "zustand";
import { updateGeoStatus, PerformerState } from "@/shared/api/geo"; import { updateGeoStatus, PerformerState } from "@/shared/api/geo";
declare global {
interface Window {
__geoInterval?: ReturnType<typeof setInterval> | null;
}
}
interface GeoState { interface GeoState {
/** Включён ли тумблер «На линии» */ /** Включён ли тумблер «На линии» */
isOnline: boolean; isOnline: boolean;
@@ -17,16 +23,30 @@ interface GeoState {
goOnline: (performerId: string) => void; goOnline: (performerId: string) => void;
/** Выключить тумблер — отправить Offline и остановить отслеживание */ /** Выключить тумблер — отправить Offline и остановить отслеживание */
goOffline: (performerId: string) => void; goOffline: (performerId: string) => void;
/** Принудительно установить состояние (для синхронизации с БД при загрузке) */
setOnline: (performerId: string, online: boolean) => void;
} }
export const useGeoStore = create<GeoState>((set, get) => ({ export const useGeoStore = create<GeoState>((set, get) => ({
isOnline: false, isOnline: typeof window !== "undefined" ? localStorage.getItem("performer_is_online") === "true" : false,
watchId: null, watchId: null,
isUpdating: false, isUpdating: false,
error: null, error: null,
lastCoords: null, lastCoords: null,
setOnline: (performerId: string, online: boolean) => {
const { isOnline, goOnline, goOffline } = get();
if (online && !isOnline) {
goOnline(performerId);
} else if (!online && isOnline) {
goOffline(performerId);
}
},
goOnline: (performerId: string) => { goOnline: (performerId: string) => {
const { watchId } = get();
if (watchId !== null) return; // Уже запущено
if (typeof window === "undefined" || !navigator.geolocation) { if (typeof window === "undefined" || !navigator.geolocation) {
set({ error: "Геолокация не поддерживается вашим браузером." }); set({ error: "Геолокация не поддерживается вашим браузером." });
return; return;
@@ -35,7 +55,7 @@ export const useGeoStore = create<GeoState>((set, get) => ({
// Таймер для периодической отправки (каждые 10 секунд) // Таймер для периодической отправки (каждые 10 секунд)
let sendInterval: ReturnType<typeof setInterval> | null = null; let sendInterval: ReturnType<typeof setInterval> | null = null;
const watchId = navigator.geolocation.watchPosition( const newWatchId = navigator.geolocation.watchPosition(
async (position) => { async (position) => {
const { latitude, longitude } = position.coords; const { latitude, longitude } = position.coords;
set({ lastCoords: { lat: latitude, lon: longitude }, error: null }); set({ lastCoords: { lat: latitude, lon: longitude }, error: null });
@@ -89,10 +109,13 @@ export const useGeoStore = create<GeoState>((set, get) => ({
}, 10000); }, 10000);
// Сохраняем watchId и intervalId в замыкании для очистки // Сохраняем watchId и intervalId в замыкании для очистки
set({ isOnline: true, watchId, error: null }); set({ isOnline: true, watchId: newWatchId, error: null });
if (typeof window !== "undefined") {
localStorage.setItem("performer_is_online", "true");
}
// Храним intervalId в window для очистки при goOffline // Храним intervalId в window для очистки при goOffline
(window as any).__geoInterval = sendInterval; window.__geoInterval = sendInterval;
}, },
goOffline: async (performerId: string) => { goOffline: async (performerId: string) => {
@@ -104,9 +127,9 @@ export const useGeoStore = create<GeoState>((set, get) => ({
} }
// Останавливаем интервал // Останавливаем интервал
if ((window as any).__geoInterval) { if (window.__geoInterval) {
clearInterval((window as any).__geoInterval); clearInterval(window.__geoInterval);
(window as any).__geoInterval = null; window.__geoInterval = null;
} }
// Отправляем статус Offline // Отправляем статус Offline
@@ -118,11 +141,14 @@ export const useGeoStore = create<GeoState>((set, get) => ({
longitude: lastCoords.lon, longitude: lastCoords.lon,
state: PerformerState.Offline, state: PerformerState.Offline,
}); });
} catch (err) { } catch (err: unknown) {
console.error("[Geo] Ошибка отправки Offline:", err); console.error("[Geo] Ошибка отправки Offline:", err);
} }
} }
set({ isOnline: false, watchId: null, lastCoords: null, error: null }); set({ isOnline: false, watchId: null, lastCoords: null, error: null });
if (typeof window !== "undefined") {
localStorage.setItem("performer_is_online", "false");
}
}, },
})); }));
+46
View File
@@ -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
View File
@@ -18,10 +18,15 @@ api.interceptors.request.use((config) => {
return config; return config;
}); });
let isRefreshing = false; interface QueueItem {
let failedQueue: any[] = []; resolve: (token: string | null) => void;
reject: (error: unknown) => void;
}
const processQueue = (error: any, token: string | null = null) => { let isRefreshing = false;
let failedQueue: QueueItem[] = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
prom.reject(error); prom.reject(error);
@@ -32,12 +37,14 @@ const processQueue = (error: any, token: string | null = null) => {
failedQueue = []; failedQueue = [];
}; };
// Response interceptor for token refresh // Response interceptor for token refresh and global error handling
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalRequest: any = error.config; const originalRequest: any = error.config;
// 1. Handle 401 Unauthorized (Token Refresh)
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -83,6 +90,29 @@ api.interceptors.response.use(
} }
} }
// 2. Global Error Handling (ProblemDetails RFC 7807)
if (error.response) {
const data = error.response.data as any;
const status = error.response.status;
// Ошибки сервера или бизнес-правил (409/500/etc)
if (status >= 500) {
const message = data?.detail || "Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.";
import("sonner").then(({ toast }) => toast.error(message));
}
else if (status === 409 || status === 422) {
// Бизнес-исключения
const message = data?.detail || "Ошибка выполнения операции.";
import("sonner").then(({ toast }) => toast.error(message));
}
else if (status === 403) {
import("sonner").then(({ toast }) => toast.error("У вас недостаточно прав для этого действия."));
}
} else {
// Ошибки сети
import("sonner").then(({ toast }) => toast.error("Ошибка сети. Проверьте подключение к интернету."));
}
return Promise.reject(error); return Promise.reject(error);
} }
); );
+6 -2
View File
@@ -5,12 +5,16 @@ export interface Category {
name: string; name: string;
slug: string; slug: string;
parentId: string | null; parentId: string | null;
attributeSchema?: any; attributeSchema?: unknown;
} }
export interface Offer { export interface Offer {
id: string; id: string;
performerId: string; performerId: string;
performerName?: string;
performerRole?: string;
rating?: number;
reviewsCount?: number;
categoryId: string; categoryId: string;
title: string; title: string;
description: string; description: string;
@@ -19,7 +23,7 @@ export interface Offer {
currency: string; currency: string;
type: number; // 0 = Fixed, 1 = Hourly, 2 = Negotiable type: number; // 0 = Fixed, 1 = Hourly, 2 = Negotiable
}; };
attributes: Record<string, any> | null; attributes: Record<string, unknown> | null;
isActive: boolean; isActive: boolean;
images: string[]; images: string[];
} }
+29
View File
@@ -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 минут
});
};
+83 -1
View File
@@ -9,10 +9,36 @@ export type OrderStatus =
| "InProgress" | "InProgress"
| "Completed" | "Completed"
| "Cancelled" | "Cancelled"
| "Expired"; | "Expired"
| "VerificationPending"
| "Disputed";
export type OrderType = "Direct" | "PublicJob"; export type OrderType = "Direct" | "PublicJob";
export type DisputeStatus =
| "WaitingForPerformer"
| "WaitingForCustomer"
| "Resolved"
| "InArbitration";
export interface DisputeMessage {
id: string;
authorId: string;
text: string;
proposedSolution: string | null;
evidence: string[];
createdAt: string;
}
export interface Dispute {
reason: string;
status: DisputeStatus;
messages: DisputeMessage[];
createdAt: string;
resolvedAt: string | null;
isResolved: boolean;
}
export interface Order { export interface Order {
id: string; id: string;
customerId: string; customerId: string;
@@ -32,6 +58,7 @@ export interface Order {
cancellationReason: string | null; cancellationReason: string | null;
/** Секунды до истечения SLA (только для PendingAcceptance) */ /** Секунды до истечения SLA (только для PendingAcceptance) */
slaSecondsLeft: number | null; slaSecondsLeft: number | null;
dispute: Dispute | null;
} }
export interface CreateOrderPayload { export interface CreateOrderPayload {
@@ -88,3 +115,58 @@ export const cancelOrder = async (
): Promise<void> => { ): Promise<void> => {
await api.post(`/orders/${orderId}/cancel`, { requesterId, reason }); await api.post(`/orders/${orderId}/cancel`, { requesterId, reason });
}; };
/** Пометить выполненным (мастер) */
export const finishByPerformer = async (orderId: string, performerId: string): Promise<void> => {
await api.post(`/orders/${orderId}/finish`, { performerId });
};
/** Подтвердить выполнение (заказчик) */
export const confirmOrderCompletion = async (orderId: string, customerId: string): Promise<void> => {
await api.post(`/orders/${orderId}/confirm`, { customerId });
};
/** Открыть спор (заказчик) */
export const disputeOrder = async (
orderId: string,
payload: {
customerId: string;
reason: string;
description: string;
proposedSolution: string;
evidence: string[];
}
): Promise<void> => {
await api.post(`/orders/${orderId}/dispute`, payload);
};
/** Ответить на спор (мастер) */
export const respondToDispute = async (
orderId: string,
payload: {
performerId: string;
position: string;
evidence: string[];
counterSolution?: string;
}
): Promise<void> => {
await api.post(`/orders/${orderId}/dispute/respond`, payload);
};
/** Возразить на ответ исполнителя (заказчик) */
export const rebutDispute = async (
orderId: string,
payload: {
customerId: string;
comment: string;
evidence: string[];
counterSolution?: string;
}
): Promise<void> => {
await api.post(`/orders/${orderId}/dispute/rebut`, payload);
};
/** Принять условия спора (заказчик) */
export const acceptDisputeTerms = async (orderId: string, customerId: string): Promise<void> => {
await api.post(`/orders/${orderId}/dispute/accept`, { customerId });
};
+167
View File
@@ -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'] });
},
});
};
+7 -4
View File
@@ -4,10 +4,11 @@ export interface SearchOffer {
id: string id: string
title: string title: string
description: string description: string
priceAmount?: number priceAmount?: number;
priceCurrency?: string priceCurrency?: string;
amount?: number imageUrl?: string;
currency?: string rating?: number;
reviewsCount?: number;
} }
export interface SearchResultItem { export interface SearchResultItem {
@@ -19,6 +20,8 @@ export interface SearchResultItem {
status: "Готов к заказу" | "Офлайн" status: "Готов к заказу" | "Офлайн"
distance?: number distance?: number
role?: string role?: string
rating?: number
reviewsCount?: number
matchedCompetencies: string[] matchedCompetencies: string[]
matchedOffers: SearchOffer[] matchedOffers: SearchOffer[]
} }
+36
View File
@@ -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;
}
+17
View File
@@ -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";
};
+29
View File
@@ -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 }
+56
View File
@@ -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 }
+78
View File
@@ -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 }
+30
View File
@@ -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 }
+156
View File
@@ -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>
)
}
+200
View File
@@ -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,
}
+177
View File
@@ -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,
}
+22
View File
@@ -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 }
+24
View File
@@ -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 }
+44
View File
@@ -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 }
+160
View File
@@ -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,
}
+27
View File
@@ -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 }
+31
View File
@@ -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 }
+46
View File
@@ -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 }
+25
View File
@@ -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
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import * as React from "react"
import { ModeToggle } from "@/components/theme/mode-toggle" import { ModeToggle } from "@/components/theme/mode-toggle"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
@@ -19,7 +20,28 @@ import { useGeoStore } from "@/features/geo/useGeoStore"
export function Header() { export function Header() {
const { isAuth, user, logout } = useSessionStore() const { isAuth, user, logout } = useSessionStore()
const { isOnline, isUpdating, error: geoError, goOnline, goOffline } = useGeoStore() const { isOnline, isUpdating, error: geoError, goOnline, goOffline, setOnline } = useGeoStore()
const [isSynced, setIsSynced] = React.useState(false)
// Синхронизируем статус при загрузке пользователя
React.useEffect(() => {
if (user && user.id) {
// 1. Синхронизация с бэкендом (только один раз при загрузке)
if (!isSynced) {
const backendOnline = user.status === "Available"
if (backendOnline !== isOnline) {
setOnline(user.id, backendOnline)
}
setIsSynced(true)
}
// 2. Если мы «в сети» по состоянию стора (напр. из localStorage),
// но процессы слежения не запущены (напр. после релоада) — запускаем их.
if (isOnline && useGeoStore.getState().watchId === null) {
goOnline(user.id)
}
}
}, [user, isOnline, setOnline, isSynced, goOnline])
// Проверяем, является ли пользователь исполнителем или компанией // Проверяем, является ли пользователь исполнителем или компанией
const isMasterOrCompany = user?.roles?.includes("Candidate") || user?.roles?.includes("Company") || user?.roles?.includes("Master") const isMasterOrCompany = user?.roles?.includes("Candidate") || user?.roles?.includes("Company") || user?.roles?.includes("Master")