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 && ( )} - {!sidebarOpen && ( - - )} + - {/* === Карта === */} -
- {isLocationLoaded ? ( - - ) : ( -
-
-
-

Определение вашей позиции...

+
+ + +
+
+
+ + Легенда +
+
+
+ Готов к заказу +
+
+
+ На заказе +
+
+
+ Офлайн
- )} -
+ +
+
+ + СПЕКТР: +
+
+
+
+
+
+
+
+
) } diff --git a/src/app/dashboard/orders/page.tsx b/src/app/dashboard/orders/page.tsx deleted file mode 100644 index 1ebe84e..0000000 --- a/src/app/dashboard/orders/page.tsx +++ /dev/null @@ -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 ( -
- - {String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")} -
- ) -} - -// ─── Статус-бейдж ──────────────────────────────────────────────────────────── - -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: , - }, - Published: { - label: "Опубликован", - className: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300", - icon: , - }, - PendingAcceptance: { - label: "Ожидает мастера", - className: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", - icon: , - }, - InProgress: { - label: "В работе", - className: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", - icon: , - }, - Completed: { - label: "Завершён", - className: - "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400", - icon: , - }, - Cancelled: { - label: "Отменён", - className: "bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400", - icon: , - }, - Expired: { - label: "Время истекло", - className: - "bg-orange-100 text-orange-600 dark:bg-orange-900/50 dark:text-orange-400", - icon: , - }, -} - -function StatusBadge({ status }: { status: OrderStatus }) { - const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.Created - return ( - - {cfg.icon} - {cfg.label} - - ) -} - -// ─── Карточка заказа ───────────────────────────────────────────────────────── - -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 ( -
- {/* Верхняя полоска с ролью */} -
-
- {isCustomer ? "🛒 Мой заказ (я клиент)" : "🛠 Заказ услуги (я мастер)"} -
- #{order.id.slice(0, 8)} -
- -
- {/* Шапка: Статус и Таймер */} -
-
- -

- {formatDate(order.createdAt)} -

-
- {order.status === "PendingAcceptance" && order.slaSecondsLeft !== null && ( -
- -
- )} -
- - {/* Блок услуги (карточка) */} -
-
-
-

- {order.serviceTitle} -

-
- - - {isCustomer - ? `Исполнитель: ${order.performerName || "Ожидание..."}` - : `Заказчик: ${order.customerName}`} - -
-
-
-

- {formatPrice(order.priceAmount, order.priceType)} -

-
-
- - {order.address && ( -
- - {order.address} -
- )} -
- - {/* Доп. инфо (отмена) */} - {order.cancellationReason && ( -
- -

Причина отмены: {order.cancellationReason}

-
- )} - - {/* Кнопки действий */} - {/* Для мастера: Принять/Отклонить */} - {!isCustomer && order.status === "PendingAcceptance" && ( -
- - -
- )} - - {/* Завершить работу (мастер или клиент) */} - {order.status === "InProgress" && ( - - )} - - {/* Клиент: Отменить (пока мастер не принял) */} - {isCustomer && order.status === "PendingAcceptance" && ( - - )} -
-
- ) -} - -// ─── Главная страница ───────────────────────────────────────────────────────── - -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("new") - const [performerTab, setPerformerTab] = React.useState("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>(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: , - 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 ( -
- -
- ) - } - - if (!user) return null - - return ( -
- {/* Заголовок */} -
-
-

- - Личный кабинет -

-

- Управление вашими заказами и услугами -

-
-
- {isFetching && ( - - - Sync - - )} - -
-
- -
- {/* 🛒 ВИДЖЕТ: ЗАКАЗЫ (Я КЛИЕНТ) */} -
-
-
-

- - Заказы -

-

Мои заказы

-
-
- -
-
- {[ - { 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 => ( - - ))} -
-
- -
- {customerQuery.isLoading ? ( -
- - Загрузка ваших заказов... -
- ) : ( - getFilteredOrders(customerQuery.data, customerTab).length === 0 ? ( -
-
- -
-

Здесь пока ничего нет

- {customerTab === "new" && ( - - )} -
- ) : ( - getFilteredOrders(customerQuery.data, customerTab).map(order => ( - - )) - ) - )} -
-
- - {/* 🛠 ВИДЖЕТ: УСЛУГИ (Я МАСТЕР) */} -
-
-
-

- - Услуги -

-

Заказы клиентов

-
-
- - {!isMaster ? ( -
-
- -
-

- Хотите зарабатывать на своих навыках? Станьте исполнителем и получайте заказы напрямую. -

- -
- ) : ( - <> -
-
- {[ - { 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 => ( - - ))} -
-
- -
- {performerQuery.isLoading ? ( -
- - Синхронизация заказов... -
- ) : ( - getFilteredOrders(performerQuery.data, performerTab).length === 0 ? ( -
-
- -
-

Новых заявок пока нет

-
- ) : ( - getFilteredOrders(performerQuery.data, performerTab).map(order => ( - - )) - ) - )} -
- - )} -
-
-
- ) -} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..2e08a28 --- /dev/null +++ b/src/app/global-error.tsx @@ -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 ( + + +
+

Упс! Что-то пошло не так

+

+ Произошла непредвиденная ошибка. Мы уже работаем над ее устранением. + {process.env.NODE_ENV === "development" && ( + + {error.message} + + )} +

+
+ + +
+
+ + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ab29d50..a1df42b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -31,10 +31,7 @@ export default function RootLayout({ > -
-
-
{children}
-
+ {children}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..b87f6f5 --- /dev/null +++ b/src/app/not-found.tsx @@ -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 ( +
+
+ +
+

404

+

Страница не найдена

+

+ Извините, но запрошенной вами страницы не существует, она была удалена или перенесена. +

+
+ +
+
+ ) +} diff --git a/src/components/home/PerformerButton.tsx b/src/components/home/PerformerButton.tsx index f412501..8ff85f6 100644 --- a/src/components/home/PerformerButton.tsx +++ b/src/components/home/PerformerButton.tsx @@ -5,7 +5,7 @@ import Link from "next/link" import { useSessionStore } from "@/entities/session/store" export function PerformerButton() { - const { isAuth, user } = useSessionStore() + const { user } = useSessionStore() // Проверяем, является ли пользователь исполнителем или компанией const isMasterOrCompany = user?.roles?.includes("Master") || user?.roles?.includes("Company") diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index f17d91c..80fc973 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button" import { useSessionStore } from "@/entities/session/store" import { useRouter, usePathname } from "next/navigation" import { useEffect } from "react" +import { RatingStats } from "@/components/reputation/RatingStats" export function Header() { const { user, isAuth, getProfile } = useSessionStore() @@ -77,6 +78,7 @@ export function Header() { Подобрать заказ )} + diff --git a/src/components/map/LiveMap.tsx b/src/components/map/LiveMap.tsx index 3aaa0fe..ad59dd0 100644 --- a/src/components/map/LiveMap.tsx +++ b/src/components/map/LiveMap.tsx @@ -3,9 +3,11 @@ import { useEffect, useState, useCallback, useRef } from "react" import { MapContainer, TileLayer, Marker, Popup, useMapEvents, useMap } from "react-leaflet" import L from "leaflet" +import { ShieldCheck } from "lucide-react" import { SearchResultItem } from "@/shared/api/search" import { Offer } from "@/shared/api/catalog" import { CreateOrderModal } from "@/components/orders/CreateOrderModal" +import { RatingStats } from "@/components/reputation/RatingStats" // Фикс иконок Leaflet для webpack/Next.js const defaultIcon = L.icon({ @@ -189,8 +191,8 @@ export default function LiveMap({ performers, isLoading, error, initialCenter = title: o.title, description: o.description ?? "", price: { - amount: o.priceAmount ?? o.amount ?? 0, - currency: o.priceCurrency ?? o.currency ?? "RUB", + amount: o.priceAmount ?? 0, + currency: o.priceCurrency ?? "RUB", type: 0, }, attributes: null, @@ -259,10 +261,16 @@ export default function LiveMap({ performers, isLoading, error, initialCenter =
-

{p.name}

+

+ + + + {p.name} +

● {p.status}

+
{p.matchedOffers.length > 0 && ( diff --git a/src/components/orders/CreateOrderModal.tsx b/src/components/orders/CreateOrderModal.tsx index ee9599c..65089f1 100644 --- a/src/components/orders/CreateOrderModal.tsx +++ b/src/components/orders/CreateOrderModal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { toast } from "sonner" -import { Loader2, ShoppingBag, MapPin, Clock, ChevronDown } from "lucide-react" +import { Loader2, ShoppingBag, MapPin, Clock } from "lucide-react" import { createOrder, CreateOrderPayload } from "@/shared/api/orders" import { Offer } from "@/shared/api/catalog" import { formatPrice as fmtPrice } from "@/shared/lib/formatPrice" @@ -26,7 +26,7 @@ interface CreateOrderModalProps { /** Имя мастера (для отображения в заказе) */ performerName?: string /** Список услуг этого мастера (для выбора) */ - offers: (Offer | any)[] + offers: Offer[] /** Предустановка конкретной услуги */ preselectedOfferId?: string userLocation?: { lat: number; lon: number; address?: string } @@ -63,7 +63,7 @@ export function CreateOrderModal({ } else if (offers.length > 0 && !selectedOfferId) { setSelectedOfferId(offers[0].id) } - }, [preselectedOfferId, offers]) + }, [preselectedOfferId, offers, selectedOfferId]) const selectedOffer = offers.find((o) => o.id === selectedOfferId) @@ -95,12 +95,14 @@ export function CreateOrderModal({ performerName: performerName, } - const orderId = await createOrder(payload) + await createOrder(payload) toast.success("Заказ успешно оформлен! Мастер получит уведомление.") onOpenChange(false) - } catch (err: any) { + } catch (err: unknown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error = err as any const message = - err?.response?.data?.detail ?? err?.response?.data ?? "Ошибка при оформлении заказа" + error?.response?.data?.detail ?? error?.response?.data ?? "Ошибка при оформлении заказа" toast.error(String(message)) } finally { setIsLoading(false) diff --git a/src/components/orders/DisputeDetailsModal.tsx b/src/components/orders/DisputeDetailsModal.tsx new file mode 100644 index 0000000..9c67595 --- /dev/null +++ b/src/components/orders/DisputeDetailsModal.tsx @@ -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 = { + "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([]); + 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 ( + + +
+
+ +
+
+ + Детали спора + +
+

Заказ #{order.id.slice(0, 8)}

+ +

{DISPUTE_REASONS_MAP[dispute.reason] || dispute.reason}

+
+
+
+ {dispute.status === "WaitingForPerformer" ? "Ожидает исполнителя" : + dispute.status === "WaitingForCustomer" ? "Ожидает заказчика" : + "Решено"} +
+
+ +
+
+ {/* История сообщений */} +
+

+ + История обсуждения +

+ +
+ {dispute.messages.map((msg) => ( +
+
+ {msg.authorId === order.customerId ? : } +
+
+
+ + {getAuthorLabel(msg.authorId)} + + + {new Date(msg.createdAt).toLocaleString("ru-RU")} + +
+
+

+ {msg.text} +

+ + {msg.proposedSolution && ( +
+

Предлагаемое решение

+

+ «{msg.proposedSolution}» +

+
+ )} + + {msg.evidence && msg.evidence.length > 0 && ( +
+ {msg.evidence.map((url, i) => ( + + Evidence + + ))} +
+ )} +
+
+
+ ))} +
+
+ + {/* Форма ответа */} + {showReplyForm ? ( +
+
+
+

Ваш ответ

+ +
+ +
+
+ +