diff --git a/README.md b/README.md
index e9f5d60..7e5f26d 100644
--- a/README.md
+++ b/README.md
@@ -1,166 +1,91 @@
# ✨ Nashel — Frontend
-> **Next.js 15 · TypeScript · React · TailwindCSS · Shadcn/UI**
+> **Next.js 15 (App Router) · TypeScript · Tailwind CSS · Shadcn/UI · Zustand**
-Клиентская часть платформы **Nashel** — маркетплейса для поиска и найма профессиональных исполнителей. Реализован как современное SPA/SSR приложение с тёмной темой, интерактивной картой и богатым UX.
+Клиентская часть платформы **Nashel** — современного маркетплейса для поиска и найма профессиональных исполнителей. Приложение спроектировано как высокопроизводительное SPA/SSR решение с глубокой проработкой пользовательского опыта, адаптивным дизайном и интерактивными картами.
---
-## 🎯 Какую проблему решает Nashel
+## 🎯 Преимущества для пользователя
-Клиенты тратят часы на поиск надёжных мастеров. Исполнители теряют заказы из-за отсутствия нормального онлайн-присутствия.
+**Клиент:**
+- **Мгновенный поиск:** Находите мастеров прямо на карте в своем районе.
+- **Инфо-карты:** Подробные страницы услуг с галереями работ, честными ценами и атрибутами.
+- **Безопасный заказ:** Оформляйте сделки и отслеживайте их статус в реальном времени.
-**Nashel** соединяет их мгновенно:
-- Ищи мастера прямо на карте рядом с домом
-- Смотри фото работ, цены и описание услуги
-- Нажми одну кнопку — и выйди на связь с исполнителем
+**Исполнитель:**
+- **Цифровой кабинет:** Удобное управление услугами, расписанием и статусом доступности.
+- **Галереи работ:** Загрузка и управление фотографиями с поддержкой Drag-and-Drop.
+- **Система рейтинга:** Накапливайте отзывы и повышайте доверие клиентов.
---
-## 👤 Роли пользователей
-
-| Роль | Возможности |
-|------|-------------|
-| **Клиент** | Поиск услуг, просмотр профилей, карта |
-| **Кандидат в мастера** | Личный кабинет, создание профиля исполнителя |
-| **Мастер** | Полный кабинет: управление услугами, расписание, статус |
-| **Компания** | Расширенный кабинет с представлением от юрлица |
-
----
-
-## 🗂️ Структура проекта
-
-```
-src/
-├── app/ # Страницы (Next.js App Router)
-│ ├── (auth)/ # Регистрация, вход
-│ ├── dashboard/ # Личный кабинет исполнителя
-│ │ ├── offers/ # Управление услугами: список, создание, редактирование
-│ │ ├── profile/ # Профиль, аватар, локация, расписание, компетенции
-│ │ └── settings/ # Настройки аккаунта
-│ ├── offers/[id]/ # Публичная страница просмотра услуги
-│ ├── search/ # Поиск услуг + карта
-│ └── layout.tsx # Общий layout с навигацией
-│
-├── components/ # Переиспользуемые компоненты
-│ ├── ui/ # Shadcn UI компоненты (Button, Card, Dialog и т.д.)
-│ ├── ImageUploader/ # Компонент загрузки, перетаскивания и удаления фото
-│ └── ...
-│
-└── shared/
- ├── api/ # Функции для обращения к API (catalog, search, auth)
- └── store/ # Zustand: геолокация пользователя
-```
-
----
-
-## ✅ Что реализовано
-
-### Аутентификация
-- Регистрация и вход (JWT в localStorage)
-- Защищённые маршруты
-- Сохранение сессии
-
-### Карта и поиск
-- Интерактивная карта на `React Leaflet` (без SSR)
-- Поиск по **названию услуги**, **описанию** и **компетенциям**
-- Результаты поиска появляются по мере ввода (от 2 символов)
-- Маркеры исполнителей на карте с всплывающими подсказками
-- Отображение расстояния до исполнителя (м/км)
-- Сортировка: «Идеально подходят», «Ближайшие», «Лучший рейтинг»
-- Фильтры поиска: по названию, описанию, компетенциям
-- Отображение услуги **приоритетнее** исполнителя (если нашлась услуга — исполнитель отдельно не показывается)
-
-### Карточка услуги в поиске
-- Большое фото с hover-анимацией (scale + fade)
-- Цена в рублях (₽), расстояние с иконкой
-- Краткое описание услуги
-- Роль и статус исполнителя (Мастер / Кандидат в мастера / Компания)
-- Кнопка «Просмотреть услугу» — открывает страницу в новой вкладке
-
-### Публичная страница услуги (`/offers/[id]`)
-- Большой блок с фото-галереей (карусель):
- - Навигация мышью (стрелки видны при наведении)
- - Навигация клавишами ← →
- - Счётчик фото (1/N)
- - Превью-ряд внизу (миниатюры) с кольцевой подсветкой активного
-- Блок описания с аккуратным заголовком
-- Блок характеристик услуги (атрибуты ключ-значение) — под описанием
-- Правая колонка: карточка исполнителя (имя, роль, рейтинг, документы)
-- Блок «Готовы заказать?» с кнопкой «Связаться с исполнителем»
-- Блок похожих услуг из категории (заглушка, готовится к наполнению)
-- Адаптивная верстка: мобильная → десктопная
-
-### Профиль исполнителя (личный кабинет)
-- Загрузка аватара (превью + сохранение)
-- Редактирование описания, компетенций (теги с поиском)
-- Установка рабочего расписания по дням недели
-- Переход в роль исполнителя
-- Смена пароля
-
-### Управление услугами
-- Список моих услуг с фото-обложкой
-- Создание и редактирование услуги:
- - **Блок загрузки фото** (первым, до 10 шт., каждое ≤ 5 МБ)
- - Drag-and-drop для перестановки фото мышью
- - Кнопка удаления под каждым фото
- - Первое фото = обложка услуги
- - Название, описание, цена (тип + сумма), атрибуты
-- Приостановка услуги (пауза)
-- Удаление услуги (soft delete)
-- Приостановленные и удалённые услуги не видны в поиске
-
----
-
-## 🚀 Запуск
-
-```bash
-cd nashel-frontend
-
-# Установить зависимости
-npm install
-
-# Запустить dev-сервер
-npm run dev
-```
-
-Приложение доступно по адресу: `http://localhost:3000`
-
-### Переменные окружения
-
-Создать файл `.env.local`:
-
-```env
-NEXT_PUBLIC_API_URL=http://localhost:5000
-```
-
----
-
-## 🔧 Технологии
+## 🛠️ Технологический стек
| Технология | Назначение |
|------------|------------|
-| **Next.js 15** (App Router) | SSR/SPA, маршрутизация |
-| **TypeScript** | Типизация |
-| **Tailwind CSS** | Стилизация |
-| **Shadcn/UI** | Готовые UI компоненты |
-| **Zustand** | Глобальное состояние (геолокация) |
-| **React Leaflet** | Карта (с отключённым SSR) |
-| **React Hook Form + Zod** | Формы и валидация |
-| **Axios** | HTTP-запросы к API |
-| **Lucide React** | Иконки |
-| **Sonner** | Тосты/уведомления |
+| **Next.js 15** | Фреймворк для SSR, маршрутизации и SEO-оптимизации |
+| **Tailwind CSS** | Адаптивная верстка и дизайн-система |
+| **Shadcn/UI** | Высококачественные доступные компоненты интерфейса |
+| **Zustand** | Глобальное состояние (геолокация, сессии) |
+| **React Leaflet** | Интерактивная карта (с динамической подгрузкой) |
+| **Axios** | Интеграция с API (авторизация, заказы, репутация) |
+| **React Hook Form** | Комплексная валидация форм (Zod) |
+
+---
+
+## ✅ Текущая реализация
+
+### 1. Поиск и Интерактивность
+- **Карта:** Отображение маркеров исполнителей, расчет дистанции до пользователя в реальном времени.
+- **Smart Search:** Поиск по заголовкам, описаниям и компетенциям с мгновенной фильтрацией.
+- **Сортировка:** По расстоянию, релевантности и качеству предложений.
+
+### 2. Страница Услуги (Offer Page)
+- **Rich Gallery:**
+ - Продвинутая карусель с поддержкой клавиатуры (← →).
+ - Превью-ряд с подсветкой активного фото.
+ - Hover-эффект увеличения и плавные переходы.
+- **Детализация:** Полный список атрибутов услуги, карточка исполнителя, статус верификации.
+
+### 3. Личный кабинет (Dashboard)
+- **Управление Офферами:**
+ - Создание услуг с мультизагрузкой фото (до 10 шт).
+ - Интерактивный порядок фото (Drag-and-Drop).
+ - Пауза и мягкое удаление услуг.
+- **Профиль:** Смена аватара, настройка сложных расписаний, управление компетенциями.
+- **Orders API:** Полная интеграция с бэкенд-модулем заказов (принятие, завершение, споры).
+
+---
+
+## 🚀 Быстрый старт
+
+1. Перейдите в директорию фронтенда:
+ ```bash
+ cd nashel-frontend
+ ```
+2. Установите зависимости:
+ ```bash
+ npm install
+ ```
+3. Создайте `.env.local`:
+ ```env
+ NEXT_PUBLIC_API_URL=http://localhost:5000
+ ```
+4. Запустите в режиме разработки:
+ ```bash
+ npm run dev
+ ```
+
+Приложение будет доступно по адресу `http://localhost:3000`
---
## 🔮 Планы развития
-- [ ] Реальный чат с исполнителем (WebSocket)
-- [ ] Страница профиля исполнителя (публичная)
-- [ ] Система отзывов и рейтингов (реальные данные)
-- [ ] Оформление заказа через платформу
-- [ ] Push-уведомления
-- [ ] Мобильное приложение (React Native)
-- [ ] Полнотекстовый фильтр по категориям и тегам
-- [ ] Похожие услуги (реальные данные из той же категории)
-- [ ] SEO-оптимизация публичных страниц
+- [ ] **Real-time Chat:** Чат между клиентом и мастером на WebSockets.
+- [ ] **Public Profiles:** Публичные страницы мастеров с портфолио и отзывами.
+- [ ] **SEO Boost:** Гидратация мета-тегов для индексации поисковиками.
+- [ ] **Push Notifications:** Уведомления о новых заказах и сообщениях.
+- [ ] **Mobile App:** Разработка нативного приложения на React Native.
+- [ ] **Verification UI:** Интерфейс загрузки документов для подтверждения статуса.
diff --git a/build.log b/build.log
deleted file mode 100644
index d83f4ef..0000000
Binary files a/build.log and /dev/null differ
diff --git a/build.log.txt b/build.log.txt
deleted file mode 100644
index 8db246e..0000000
--- a/build.log.txt
+++ /dev/null
@@ -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
diff --git a/build_output.txt b/build_output.txt
deleted file mode 100644
index 6fab0ce..0000000
Binary files a/build_output.txt and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
index 85bdf66..646aca4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
@@ -17,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.90.21",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/react": "^3.19.0",
@@ -34,7 +36,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"react-easy-crop": "^5.5.6",
- "react-hook-form": "^7.71.1",
+ "react-hook-form": "^7.71.2",
"react-image-crop": "^11.0.10",
"react-leaflet": "^5.0.0",
"sonner": "^1.7.1",
@@ -1675,6 +1677,35 @@
}
}
},
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -6815,9 +6846,9 @@
}
},
"node_modules/react-hook-form": {
- "version": "7.71.1",
- "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
- "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
+ "version": "7.71.2",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
+ "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
diff --git a/package.json b/package.json
index 90ac086..5562278 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
@@ -18,6 +19,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.90.21",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/react": "^3.19.0",
@@ -35,7 +37,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"react-easy-crop": "^5.5.6",
- "react-hook-form": "^7.71.1",
+ "react-hook-form": "^7.71.2",
"react-image-crop": "^11.0.10",
"react-leaflet": "^5.0.0",
"sonner": "^1.7.1",
@@ -55,5 +57,5 @@
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
- "packageManager": "pnpm@9.1.4"
+ "packageManager": "npm"
}
diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx
new file mode 100644
index 0000000..430c366
--- /dev/null
+++ b/src/app/(admin)/admin/page.tsx
@@ -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 (
+
+
Обзор Панели Управления
+
+ {/* KPI Cards Placeholder */}
+
+
+
+ Пользователей
+
+
+
+ +12,234
+ +19% с прошлого месяца
+
+
+
+
+ Активных Заказов
+
+
+
+ +573
+ +5% с прошлого месяца
+
+
+
+
+ Открытые Споры
+
+
+
+ 12
+ Требуют внимания арбитра
+
+
+
+
+ {/* Recent Disputes or Activity Placeholder */}
+
Деятельность
+
+
Данные загружаются...
+
+
+ );
+}
diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx
new file mode 100644
index 0000000..e8f6fbe
--- /dev/null
+++ b/src/app/(admin)/layout.tsx
@@ -0,0 +1,25 @@
+export default function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {/* Sidebar Placeholder */}
+
+
+ {/* Main Content Area */}
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/auth/login/page.tsx b/src/app/(main)/auth/login/page.tsx
similarity index 100%
rename from src/app/auth/login/page.tsx
rename to src/app/(main)/auth/login/page.tsx
diff --git a/src/app/auth/register/page.tsx b/src/app/(main)/auth/register/page.tsx
similarity index 100%
rename from src/app/auth/register/page.tsx
rename to src/app/(main)/auth/register/page.tsx
diff --git a/src/app/dashboard/become-performer/page.tsx b/src/app/(main)/dashboard/become-performer/page.tsx
similarity index 99%
rename from src/app/dashboard/become-performer/page.tsx
rename to src/app/(main)/dashboard/become-performer/page.tsx
index 6533949..6424d95 100644
--- a/src/app/dashboard/become-performer/page.tsx
+++ b/src/app/(main)/dashboard/become-performer/page.tsx
@@ -8,7 +8,7 @@ import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { useSessionStore } from '@/entities/session/store'
import { Loader2, Clock, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'
@@ -171,11 +171,6 @@ export default function BecomePerformerPage() {
}
}
- const handleScheduleOpen = () => {
- form.setValue('workSchedule.isAlwaysReady', false)
- setScheduleCollapsed(false)
- }
-
// Поиск адресов через Nominatim API (как в TagInput - только лучшее совпадение)
React.useEffect(() => {
const searchAddress = async () => {
@@ -194,6 +189,7 @@ export default function BecomePerformerPage() {
}
)
const data = await response.json()
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const addresses = data.map((item: any) => {
const city = item.address.city || item.address.town || item.address.village || ''
const street = item.address.road || ''
diff --git a/src/app/dashboard/cabinet/page.tsx b/src/app/(main)/dashboard/cabinet/page.tsx
similarity index 66%
rename from src/app/dashboard/cabinet/page.tsx
rename to src/app/(main)/dashboard/cabinet/page.tsx
index 982ce93..fab0401 100644
--- a/src/app/dashboard/cabinet/page.tsx
+++ b/src/app/(main)/dashboard/cabinet/page.tsx
@@ -15,13 +15,13 @@ import {
RefreshCw,
ShoppingBag,
User,
+ Star,
MapPin,
ArrowRight,
- CreditCard,
+ Gavel,
} from "lucide-react"
import { useSessionStore } from "@/entities/session/store"
import { formatPrice } from "@/shared/lib/formatPrice"
-import { Badge } from "@/components/ui/badge"
import {
Order,
OrderStatus,
@@ -30,9 +30,18 @@ import {
acceptOrder,
completeOrder,
cancelOrder,
+ finishByPerformer,
+ confirmOrderCompletion,
} from "@/shared/api/orders"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
+import { ReviewModal } from "@/components/reputation/ReviewModal"
+import { DisputeModal } from "@/components/orders/DisputeModal"
+import { DisputeResponseModal } from "@/components/orders/DisputeResponseModal"
+import { useProfileReviews, useProfileRating } from "@/shared/api/reputation"
+import { ReviewCard } from "@/components/reputation/ReviewCard"
+import { getRatingBadgeClasses, getRatingStarColor } from "@/shared/lib/ratingColors"
+import { DisputeDetailsModal } from "@/components/orders/DisputeDetailsModal"
// ─── SLA Countdown ──────────────────────────────────────────────────────────
@@ -109,6 +118,16 @@ const STATUS_CONFIG: Record<
"bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400",
icon: ,
},
+ VerificationPending: {
+ label: "На проверке",
+ className: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300",
+ icon: ,
+ },
+ Disputed: {
+ label: "В споре",
+ className: "bg-rose-100 text-rose-700 dark:bg-rose-900/50 dark:text-rose-300",
+ icon: ,
+ },
}
function StatusBadge({ status }: { status: OrderStatus }) {
@@ -131,10 +150,21 @@ function StatusBadge({ status }: { status: OrderStatus }) {
interface OrderCardProps {
order: Order
userId: string
- isPerformerRole: boolean
+ onOpenReview?: (orderId: string, offerId: string, authorId: string, targetId: string, targetName: string) => void
+ onOpenDispute?: (orderId: string, customerId: string) => void
+ onOpenDisputeResponse?: (orderId: string, performerId: string) => void
+ onOpenDisputeDetails?: (order: Order) => void
}
-function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
+const DISPUTE_REASONS_MAP: Record = {
+ "WorkNotPerformed": "Работы не выполнены",
+ "NotFullVolume": "Выполнены не в полном объеме",
+ "PoorQuality": "Выполнены некачественно",
+ "DeadlineViolated": "Нарушен срок",
+ "PriceIncreased": "Увеличилась стоимость",
+}
+
+function OrderCard({ order, userId, onOpenReview, onOpenDispute, onOpenDisputeResponse, onOpenDisputeDetails }: OrderCardProps) {
const qc = useQueryClient()
const router = useRouter()
@@ -142,40 +172,67 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
qc.invalidateQueries({ queryKey: ["orders"] })
}
- const acceptMutation = useMutation({
+ const mutateAccept = useMutation({
mutationFn: () => acceptOrder(order.id, userId),
onSuccess: () => {
toast.success("Заказ принят!")
invalidate()
},
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при принятии заказа"),
})
- const completeMutation = useMutation({
+ const mutateComplete = useMutation({
mutationFn: () => completeOrder(order.id, userId),
onSuccess: () => {
toast.success("Заказ завершён!")
invalidate()
},
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при завершении заказа"),
})
- const cancelMutation = useMutation({
+ const mutateCancel = useMutation({
mutationFn: (reason: string) => cancelOrder(order.id, userId, reason),
onSuccess: () => {
toast.success("Заказ отменён")
invalidate()
},
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) =>
toast.error(err?.response?.data?.detail ?? "Ошибка при отмене заказа"),
})
+ const mutateFinishByPerformer = useMutation({
+ mutationFn: () => finishByPerformer(order.id, userId),
+ onSuccess: () => {
+ toast.success("Работа отправлена на проверку!")
+ invalidate()
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при завершении работы"),
+ })
+
+ const mutateConfirmCompletion = useMutation({
+ mutationFn: () => confirmOrderCompletion(order.id, userId),
+ onSuccess: () => {
+ toast.success("Выполнение подтверждено. Заказ завершён!")
+ invalidate()
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onError: (err: any) =>
+ toast.error(err?.response?.data?.detail ?? "Ошибка при подтверждении"),
+ })
+
const isLoading =
- acceptMutation.isPending ||
- completeMutation.isPending ||
- cancelMutation.isPending
+ mutateAccept.isPending ||
+ mutateComplete.isPending ||
+ mutateCancel.isPending ||
+ mutateFinishByPerformer.isPending ||
+ mutateConfirmCompletion.isPending
const isCustomer = order.customerId === userId
@@ -262,6 +319,27 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
+ {/* Инфо о споре */}
+ {order.status === "Disputed" && order.dispute && (
+ 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"
+ >
+
+
+
+
+
Идет спор
+
{DISPUTE_REASONS_MAP[order.dispute.reason] || order.dispute.reason}
+
+
+
+
+
+ )}
+
{/* Доп. инфо (отмена) */}
{order.cancellationReason && (
@@ -281,9 +359,9 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
)}
- {/* Завершить работу (мастер или клиент) */}
- {order.status === "InProgress" && (
+ {/* Завершить работу (мастер) */}
+ {!isCustomer && order.status === "InProgress" && (
)}
+ {/* Клиент: Подтвердить / Оспорить */}
+ {isCustomer && order.status === "VerificationPending" && (
+
+
+
+
+ )}
+
+ {/* Мастер: Ответить на спор */}
+ {!isCustomer && order.status === "Disputed" && (
+
+ )}
+
{/* Клиент: Отменить (пока мастер не принял) */}
{isCustomer && order.status === "PendingAcceptance" && (
)}
+
+ {/* Клиент: Оценить работу (для завершённых заказов) */}
+ {isCustomer && order.status === "Completed" && order.performerId && (
+
+ )}
@@ -343,10 +473,10 @@ function OrderCard({ order, userId, isPerformerRole }: OrderCardProps) {
// ─── Главная страница ─────────────────────────────────────────────────────────
const NEW_STATUSES: OrderStatus[] = ["PendingAcceptance", "Published"]
-const PROGRESS_STATUSES: OrderStatus[] = ["InProgress"]
+const PROGRESS_STATUSES: OrderStatus[] = ["InProgress", "VerificationPending", "Disputed"]
const DONE_STATUSES: OrderStatus[] = ["Completed", "Cancelled", "Expired"]
-type OrderTab = "new" | "process" | "done"
+type OrderTab = "new" | "process" | "done" | "reviews"
export default function OrdersDashboardPage() {
const { user, isAuth, isLoading: isAuthLoading, isInitialized } = useSessionStore()
@@ -355,6 +485,86 @@ export default function OrdersDashboardPage() {
const [customerTab, setCustomerTab] = React.useState("new")
const [performerTab, setPerformerTab] = React.useState("new")
+ // Состояние для модалки отзыва
+ const [reviewModal, setReviewModal] = React.useState<{
+ isOpen: boolean;
+ orderId: string;
+ offerId: string;
+ authorId: string;
+ targetId: string;
+ targetName: string;
+ }>({
+ isOpen: false,
+ orderId: '',
+ offerId: '',
+ authorId: '',
+ targetId: '',
+ targetName: ''
+ })
+
+ const openReviewModal = (orderId: string, offerId: string, authorId: string, targetId: string, targetName: string) => {
+ setReviewModal({ isOpen: true, orderId, offerId, authorId, targetId, targetName })
+ }
+
+ const closeReviewModal = () => {
+ setReviewModal(prev => ({ ...prev, isOpen: false }))
+ }
+
+ // Состояние для модалки спора
+ const [disputeModal, setDisputeModal] = React.useState<{
+ isOpen: boolean;
+ orderId: string;
+ customerId: string;
+ }>({
+ isOpen: false,
+ orderId: '',
+ customerId: '',
+ })
+
+ const openDisputeModal = (orderId: string, customerId: string) => {
+ setDisputeModal({ isOpen: true, orderId, customerId })
+ }
+
+ const closeDisputeModal = () => {
+ setDisputeModal(prev => ({ ...prev, isOpen: false }))
+ }
+
+ // Состояние для модалки ответа на спор
+ const [disputeResponseModal, setDisputeResponseModal] = React.useState<{
+ isOpen: boolean;
+ orderId: string;
+ performerId: string;
+ }>({
+ isOpen: false,
+ orderId: '',
+ performerId: '',
+ })
+
+ const openDisputeResponseModal = (orderId: string, performerId: string) => {
+ setDisputeResponseModal({ isOpen: true, orderId, performerId })
+ }
+
+ const closeDisputeResponseModal = () => {
+ setDisputeResponseModal(prev => ({ ...prev, isOpen: false }))
+ }
+
+ // Состояние для модалки деталей спора (новая)
+ const [disputeDetailsModal, setDisputeDetailsModal] = React.useState<{
+ isOpen: boolean;
+ order: Order | null;
+ }>({
+ isOpen: false,
+ order: null,
+ })
+
+ const openDisputeDetailsModal = (order: Order) => {
+ setDisputeDetailsModal({ isOpen: true, order })
+ }
+
+ const closeDisputeDetailsModal = () => {
+ setDisputeDetailsModal(prev => ({ ...prev, isOpen: false, order: null }))
+ }
+
// Редирект если не авторизован
React.useEffect(() => {
if (isInitialized && !isAuthLoading && !isAuth) {
@@ -424,11 +634,14 @@ export default function OrdersDashboardPage() {
allOrdersMap.set(o.id, { order: o, isPerformerRole: true })
}
})
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const allOrders = Array.from(allOrdersMap.values())
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const isLoading =
(customerQuery.isLoading || performerQuery.isLoading) && !customerQuery.data && !performerQuery.data
const isFetching = customerQuery.isFetching || performerQuery.isFetching
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const error = customerQuery.error || performerQuery.error
const getFilteredOrders = (data: Order[] | undefined, tab: OrderTab) => {
@@ -450,6 +663,9 @@ export default function OrdersDashboardPage() {
const customerCounts = getCounts(customerQuery.data)
const performerCounts = getCounts(performerQuery.data)
+ const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(user?.id)
+ const { data: ratingData } = useProfileRating(user?.id)
+
// Ждём инициализации сессии
if (!isInitialized || isAuthLoading) {
return (
@@ -493,7 +709,7 @@ export default function OrdersDashboardPage() {
-
+
{/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */}
@@ -556,7 +772,7 @@ export default function OrdersDashboardPage() {
) : (
getFilteredOrders(customerQuery.data, customerTab).map(order => (
-
+
))
)
)}
@@ -634,7 +850,7 @@ export default function OrdersDashboardPage() {
) : (
getFilteredOrders(performerQuery.data, performerTab).map(order => (
-
+
))
)
)}
@@ -642,7 +858,83 @@ export default function OrdersDashboardPage() {
>
)}
+
+ {/* 🌟 ВИДЖЕТ: ОТЗЫВЫ (Я МАСТЕР) */}
+
+
+
+
+
+ Мои отзывы
+
+
Что говорят клиенты
+
+ {ratingData && (
+
+ {ratingData.averageRating.toFixed(1)}
+
+
+ )}
+
+
+
+ {isReviewsLoading ? (
+
+
+ Загрузка отзывов...
+
+ ) : !reviewsData || reviewsData.reviews.length === 0 ? (
+
+
+
+
+
Отзывов пока нет
+
+ ) : (
+ reviewsData.reviews.map(review => (
+
+ ))
+ )}
+
+
+
+ {/* Модалка отзыва */}
+
+
+ {/* Модалка спора */}
+
+
+ {/* Модалка ответа на спор */}
+
+
+ {/* Модалка деталей спора (новая) */}
+ {disputeDetailsModal.order && (
+
+ )}
)
}
diff --git a/src/app/dashboard/offers/[id]/edit/page.tsx b/src/app/(main)/dashboard/offers/[id]/edit/page.tsx
similarity index 98%
rename from src/app/dashboard/offers/[id]/edit/page.tsx
rename to src/app/(main)/dashboard/offers/[id]/edit/page.tsx
index 7454dd8..8eac1eb 100644
--- a/src/app/dashboard/offers/[id]/edit/page.tsx
+++ b/src/app/(main)/dashboard/offers/[id]/edit/page.tsx
@@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
- FormDescription,
FormField,
FormItem,
FormLabel,
@@ -24,10 +23,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Loader2, Plus, Trash2 } from "lucide-react";
-import { updateOffer, getOfferById, Category } from "@/shared/api/catalog";
+import { updateOffer, getOfferById } from "@/shared/api/catalog";
import { useSessionStore } from "@/entities/session/store";
import { ImageUploader } from "@/components/ui/image-uploader";
import { useParams } from "next/navigation";
@@ -111,6 +110,8 @@ export default function EditOfferPage() {
images: offerData.images || [],
});
} catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
toast.error("Не удалось загрузить данные услуги");
router.push("/dashboard/offers");
} finally {
@@ -145,6 +146,8 @@ export default function EditOfferPage() {
toast.success("Услуга успешно обновлена");
router.push("/dashboard/offers");
} catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
toast.error("Не удалось обновить услугу. Проверьте данные и попробуйте снова.");
} finally {
setIsSubmitting(false);
diff --git a/src/app/dashboard/offers/create/page.tsx b/src/app/(main)/dashboard/offers/create/page.tsx
similarity index 98%
rename from src/app/dashboard/offers/create/page.tsx
rename to src/app/(main)/dashboard/offers/create/page.tsx
index 734399f..9b9a8e7 100644
--- a/src/app/dashboard/offers/create/page.tsx
+++ b/src/app/(main)/dashboard/offers/create/page.tsx
@@ -24,7 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { getCategories, createOffer, Category } from "@/shared/api/catalog";
@@ -93,6 +93,8 @@ export default function CreateOfferPage() {
const data = await getCategories();
setCategories(data || []);
} catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
toast.error("Ошибка загрузки категорий");
} finally {
setLoading(false);
@@ -126,6 +128,8 @@ export default function CreateOfferPage() {
toast.success("Услуга успешно создана");
router.push("/dashboard/offers");
} catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
toast.error("Не удалось создать услугу. Проверьте данные и попробуйте снова.");
} finally {
setIsSubmitting(false);
@@ -286,7 +290,7 @@ export default function CreateOfferPage() {
Дополнительные характеристики
-
Добавьте важные детали ("Опыт", "Гарантия")
+
Добавьте важные детали ("Опыт", "Гарантия")
diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx
new file mode 100644
index 0000000..e5c28a6
--- /dev/null
+++ b/src/app/(main)/layout.tsx
@@ -0,0 +1,14 @@
+import { Header } from "@/widgets/Header";
+
+export default function MainLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/app/offers/[id]/page.tsx b/src/app/(main)/offers/[id]/page.tsx
similarity index 77%
rename from src/app/offers/[id]/page.tsx
rename to src/app/(main)/offers/[id]/page.tsx
index a3e1131..8392bb5 100644
--- a/src/app/offers/[id]/page.tsx
+++ b/src/app/(main)/offers/[id]/page.tsx
@@ -4,10 +4,15 @@ import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { getOfferById, Offer } from "@/shared/api/catalog"
import { Button } from "@/components/ui/button"
-import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, Clock, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag } from "lucide-react"
+import { ArrowLeft, MapPin, Image as ImageIcon, Briefcase, FileText, CheckCircle2, Star, User, ShieldCheck, ChevronLeft, ChevronRight, ListOrdered, ShoppingBag, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { CreateOrderModal } from "@/components/orders/CreateOrderModal"
import { formatPrice } from "@/shared/lib/formatPrice"
+import { useProfileRating, useProfileReviews, useOfferRating } from "@/shared/api/reputation"
+import { usePublicProfile } from "@/shared/api/identity"
+import { Rating } from "@/components/ui/rating"
+import { ReviewCard } from "@/components/reputation/ReviewCard"
+
export default function PublicOfferPage() {
const params = useParams()
@@ -30,7 +35,9 @@ export default function PublicOfferPage() {
if (data.images && data.images.length > 0) {
setActiveImageIndex(0)
}
- } catch (error: any) {
+ } catch (err: unknown) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const error = err as any
toast.error("Не удалось загрузить услугу")
console.error("Fetch offer error:", error)
} finally {
@@ -41,6 +48,11 @@ export default function PublicOfferPage() {
fetchOffer()
}, [offerId])
+ const { data: ratingData } = useProfileRating(offer?.performerId)
+ const { data: reviewsData, isLoading: isReviewsLoading } = useProfileReviews(offer?.performerId)
+ const { data: offerRatingData } = useOfferRating(offerId)
+ const { data: performerPublicData } = usePublicProfile(offer?.performerId)
+
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!offer || !offer.images || offer.images.length <= 1) return;
@@ -206,13 +218,50 @@ export default function PublicOfferPage() {
{Object.entries(offer.attributes || {}).map(([key, value]) => (
{key}
- {value}
+ {String(value)}
))}
)}
+
+ {/* Отзывы */}
+
+
+
+
+
+ Отзывы ({ratingData?.totalReviews || 0})
+
+ {offerRatingData && offerRatingData.totalReviews > 0 && (
+
+ Рейтинг услуги: {offerRatingData.averageRating.toFixed(1)}
+
+
+ )}
+
+
+
+ {isReviewsLoading ? (
+
+
+ Загрузка отзывов...
+
+ ) : !reviewsData || reviewsData.reviews.length === 0 ? (
+
+
+
Об этом мастере пока нет отзывов.
+
+ ) : (
+
+ {reviewsData.reviews.map(review => (
+
+ ))}
+
+ )}
+
+
{/* Правая колонка - Карточка заказа / Информация */}
@@ -221,39 +270,38 @@ export default function PublicOfferPage() {
Об исполнителе
-
+
-
-
Имя Фамилия
-
Мастер
+
+
+
+
+
+ {performerPublicData?.fullName || offer.performerName || 'Мастер'}
+
+
+ {performerPublicData?.primaryRole || offer.performerRole || 'Специалист'}
+
-
-
-
- 5.0
-
-
- 42 отзыва
-
-
-
-
-
-
- Документы проверены
-
-
-
- На сайте 2 года
-
+
+
+
+
+ {ratingData?.averageRating.toFixed(1) || '0.0'}
+
+ / 5.0
+
+
+
+
-
diff --git a/src/app/page.tsx b/src/app/(main)/page.tsx
similarity index 100%
rename from src/app/page.tsx
rename to src/app/(main)/page.tsx
diff --git a/src/app/search/page.tsx b/src/app/(main)/search/page.tsx
similarity index 65%
rename from src/app/search/page.tsx
rename to src/app/(main)/search/page.tsx
index a69c7a5..267d8e0 100644
--- a/src/app/search/page.tsx
+++ b/src/app/(main)/search/page.tsx
@@ -1,15 +1,25 @@
"use client"
import dynamic from "next/dynamic"
+import * as React from "react"
import { useState, useCallback, useRef, useEffect, useMemo } from "react"
-import { Search, User, Filter, MapPin, Briefcase } from "lucide-react"
-import { SearchResultItem, SearchParams, globalSearch } from "@/shared/api/search"
+import {
+ Search as SearchIcon,
+ MapPin,
+ Star,
+ Hammer,
+ User,
+ SearchCheck,
+ ListOrdered
+} from "lucide-react"
+import { globalSearch, SearchResultItem } from "@/shared/api/search"
+import { getRatingBadgeClasses, getRatingStarColor } from "@/shared/lib/ratingColors"
import { formatPrice } from "@/shared/lib/formatPrice"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { cn } from "@/lib/utils"
-// ВАЖНО: react-leaflet ломает SSR — импортируем с ssr: false
const LiveMap = dynamic(() => import("@/components/map/LiveMap"), {
ssr: false,
loading: () => (
@@ -31,18 +41,15 @@ export default function SearchPage() {
const [centerTrigger, setCenterTrigger] = useState(0)
const [sortBy, setSortBy] = useState
("default")
- // Параметры поиска
const [query, setQuery] = useState("")
const [inTitle, setInTitle] = useState(true)
const [inDesc, setInDesc] = useState(true)
const [inComp, setInComp] = useState(true)
- // Текущая позиция карты (и инициализация из localStorage)
const [mapBounds, setMapBounds] = useState<{ lat: number, lon: number, radius: number } | null>(null)
- const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173]) // Default: Moscow
- const [isLocationLoaded, setIsLocationLoaded] = useState(false)
+ const [initialCenter, setInitialCenter] = useState<[number, number]>([55.7558, 37.6173])
+ const [, setIsLocationLoaded] = useState(false)
- // Пытаемся восстановить локацию при монтировании
useEffect(() => {
try {
const savedProfile = localStorage.getItem("user-profile")
@@ -88,7 +95,6 @@ export default function SearchPage() {
}
}, [])
- // Debounced search trigger
const triggerSearch = useCallback((newQuery: string, lat?: number, lon?: number, rad?: number, title?: boolean, desc?: boolean, comp?: boolean) => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
@@ -96,7 +102,7 @@ export default function SearchPage() {
const currentLon = lon ?? mapBounds?.lon
const currentRad = rad ?? mapBounds?.radius
- if (!currentLat || !currentLon) return // Ждём, пока карта даст координаты
+ if (!currentLat || !currentLon) return
searchTimeoutRef.current = setTimeout(() => {
performSearch(
@@ -123,18 +129,15 @@ export default function SearchPage() {
const handlePerformerClick = useCallback((id: string) => {
setActivePerformerId(id)
+ const scrollId = `performer-card-${id}`
if (sidebarOpen) {
- const el = document.getElementById(`performer-card-${id}`)
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
- }
+ const el = document.getElementById(scrollId)
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else {
setSidebarOpen(true)
setTimeout(() => {
- const el = document.getElementById(`performer-card-${id}`)
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
- }
+ const el = document.getElementById(scrollId)
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300)
}
}, [sidebarOpen])
@@ -145,11 +148,12 @@ export default function SearchPage() {
}, [])
const sortedItems = useMemo(() => {
- let items: { type: 'offer' | 'performer', performer: SearchResultItem, offer?: any, distance: number, price: number }[] = [];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const items: { type: 'offer' | 'performer', performer: SearchResultItem, offer?: any, distance: number, price: number }[] = [];
performers.forEach(p => {
if (p.matchedOffers && p.matchedOffers.length > 0) {
p.matchedOffers.forEach(o => {
- items.push({ type: 'offer', performer: p, offer: o, distance: p.distance || 0, price: o.priceAmount ?? o.amount ?? 0 });
+ items.push({ type: 'offer', performer: p, offer: o, distance: p.distance || 0, price: o.priceAmount ?? 0 });
});
} else {
items.push({ type: 'performer', performer: p, distance: p.distance || 0, price: 0 });
@@ -168,14 +172,12 @@ export default function SearchPage() {
return (
- {/* === Боковая панель: Поиск + Список мастеров === */}
{sidebarOpen && (