LocalPro Finder v2 — без AI-диагностики, Coming Soon окно
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# LocalPro Finder — v2 (Без AI-диагностики)
|
||||
|
||||
Площадка для поиска мастеров рядом с вами. Версия 2 без AI-агента диагностики.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Требования
|
||||
- Docker + Docker Compose
|
||||
- PostgreSQL 16+
|
||||
- Redis 7+
|
||||
|
||||
### Запуск через Docker Compose
|
||||
|
||||
```bash
|
||||
# 1. Скопируйте .env.example в .env и заполните переменные
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# 2. Запустите стек
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. Проверьте что всё работает
|
||||
curl http://localhost:8000/api/health
|
||||
```
|
||||
|
||||
## 📦 Фичи v2
|
||||
|
||||
- ✅ Регистрация / Вход (JWT)
|
||||
- ✅ Создание проектов (запрос на услугу)
|
||||
- ✅ Назначение мастера на проект
|
||||
- ✅ Система отзывов и рейтингов
|
||||
- ✅ Встроенный чат между клиентом и мастером
|
||||
- ✅ Подписки (Premium для мастеров)
|
||||
- ⏳ AI Диагностика — Coming Soon
|
||||
|
||||
## 📁 Структура
|
||||
|
||||
```
|
||||
localpro-finder-v2/
|
||||
├── backend/ # FastAPI бэкенд
|
||||
│ ├── src/api/routes/
|
||||
│ │ ├── auth.py # Регистрация, логин, JWT
|
||||
│ │ ├── projects.py # Создание проектов
|
||||
│ │ ├── reviews.py # Отзывы и рейтинги
|
||||
│ │ ├── chats.py # Чат между клиентом и мастером
|
||||
│ │ └── subscriptions.py # Подписки мастеров
|
||||
├── frontend/ # Next.js фронтенд
|
||||
│ ├── src/pages/
|
||||
│ │ ├── index.tsx # Главная (поиск, категории)
|
||||
│ │ ├── login.tsx # Вход
|
||||
│ │ └── register.tsx # Регистрация
|
||||
├── docker-compose.yml
|
||||
```
|
||||
|
||||
## 🔧 API Endpoints
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| POST | `/api/auth/register` | Регистрация пользователя |
|
||||
| POST | `/api/auth/login` | Вход (JWT) |
|
||||
| POST | `/api/projects/` | Создать проект |
|
||||
| POST | `/api/projects/{id}/assign-master` | Назначить мастера |
|
||||
| GET | `/api/reviews/master/{id}` | Отзывы мастера |
|
||||
| POST | `/api/chats/project/{id}/send` | Отправить сообщение |
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT © 2026 LocalPro Finder
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.37
|
||||
alembic==1.14.1
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.7.1
|
||||
pydantic[email]==2.9.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
openai==1.58.1
|
||||
redis[hiredis]==5.2.1
|
||||
celery==5.4.0
|
||||
stripe==11.3.0
|
||||
python-multipart==0.0.18
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Роуты API — reviews, ratings, chat, projects, auth"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .reviews import router as reviews_router
|
||||
from .ratings import router as ratings_router
|
||||
from .chats import router as chats_router
|
||||
from .projects import router as projects_router
|
||||
from .auth import router as auth_router
|
||||
from .diagnosis import router as diagnosis_router
|
||||
from .price_estimate import router as price_estimate_router
|
||||
from .subscriptions import router as subscriptions_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||
router.include_router(reviews_router, prefix="/reviews", tags=["Reviews"])
|
||||
router.include_router(ratings_router, prefix="/ratings", tags=["Ratings"])
|
||||
router.include_router(chats_router, prefix="/chats", tags=["Chat"])
|
||||
router.include_router(projects_router, prefix="/projects", tags=["Projects"])
|
||||
router.include_router(diagnosis_router, prefix="/diagnosis", tags=["Diagnosis"])
|
||||
router.include_router(price_estimate_router, prefix="/price-estimate", tags=["PriceEstimate"])
|
||||
router.include_router(subscriptions_router, prefix="/subscriptions", tags=["Subscriptions"])
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Аутентификация — регистрация, логин, JWT-токены"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ...core.database import async_session_factory, User, UserRole
|
||||
from ...utils.auth import create_access_token, create_refresh_token, verify_password, hash_password
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
role: str = "client" # client | master
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Регистрация пользователя."""
|
||||
|
||||
existing = await session.execute(select(User).where(User.email == req.email))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "Email уже зарегистрирован")
|
||||
|
||||
user = User(
|
||||
email=req.email,
|
||||
password_hash=hash_password(req.password),
|
||||
role=UserRole(req.role),
|
||||
first_name=req.first_name,
|
||||
last_name=req.last_name,
|
||||
phone=req.phone,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
access_token = create_access_token(str(user.id))
|
||||
refresh_token = create_refresh_token(str(user.id))
|
||||
|
||||
return {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role.value,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Логин."""
|
||||
|
||||
result = await session.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(401, "Неверный email или пароль")
|
||||
|
||||
access_token = create_access_token(str(user.id))
|
||||
refresh_token = create_refresh_token(str(user.id))
|
||||
|
||||
return {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role.value,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(req: dict, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Обновить токен."""
|
||||
|
||||
from ...utils.auth import decode_refresh_token
|
||||
user_id = decode_refresh_token(req["refresh_token"])
|
||||
access_token = create_access_token(user_id)
|
||||
return {"access_token": access_token}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить данные текущего пользователя."""
|
||||
|
||||
user = await session.get(User, uuid.UUID(str(user.id)))
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
"role": user.role.value,
|
||||
"avatar_url": user.avatar_url,
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Чат — WebSocket + REST для сообщений"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus
|
||||
from ...utils.auth import get_current_user
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
chat_id: uuid.UUID
|
||||
content_type: str = "text" # text | image | file | voice
|
||||
content: str | None = None
|
||||
media_url: str | None = None
|
||||
reply_to_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
@router.post("/messages")
|
||||
async def send_message(req: SendMessageRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Отправить сообщение в чат."""
|
||||
|
||||
chat = await session.get(Chat, req.chat_id)
|
||||
if not chat or chat.status != ChatStatus.ACTIVE:
|
||||
raise HTTPException(400, "Чат неактивен")
|
||||
|
||||
message = ChatMessage(
|
||||
chat_id=req.chat_id,
|
||||
sender_id=user.id,
|
||||
content_type=ContentType(req.content_type),
|
||||
content=req.content,
|
||||
media_url=req.media_url,
|
||||
reply_to_id=req.reply_to_id,
|
||||
)
|
||||
session.add(message)
|
||||
|
||||
from datetime import datetime
|
||||
chat.last_message_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
return {"id": str(message.id), "status": "sent"}
|
||||
|
||||
|
||||
@router.get("/messages/{chat_id}")
|
||||
async def get_messages(chat_id: uuid.UUID, limit: int = 50, before: uuid.UUID | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить историю сообщений чата."""
|
||||
|
||||
query = select(ChatMessage).where(ChatMessage.chat_id == chat_id)
|
||||
if before:
|
||||
query = query.where(ChatMessage.id < before)
|
||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
messages = [m.mapped() for m in result.scalars().all()]
|
||||
return list(reversed(messages))
|
||||
|
||||
|
||||
@router.get("/chats")
|
||||
async def get_user_chats(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить все чаты пользователя."""
|
||||
|
||||
query = select(Chat).where(
|
||||
(Chat.client_id == user.id) | (Chat.master_id == user.id),
|
||||
Chat.status.in_([ChatStatus.ACTIVE, ChatStatus.COMPLETED])
|
||||
).order_by(Chat.last_message_at.desc())
|
||||
|
||||
result = await session.execute(query)
|
||||
return [c.mapped() for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/mark-read")
|
||||
async def mark_as_read(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Отметить сообщения как прочитанные."""
|
||||
|
||||
await session.execute(
|
||||
update(ChatMessage)
|
||||
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.sender_id != user.id) & (ChatMessage.read_at.is_(None)))
|
||||
.values(read_at=datetime.utcnow())
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/archive")
|
||||
async def archive_chat(chat_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Заархивировать чат."""
|
||||
|
||||
chat = await session.get(Chat, chat_id)
|
||||
if not chat or (chat.client_id != user.id and chat.master_id != user.id):
|
||||
raise HTTPException(403)
|
||||
|
||||
chat.status = ChatStatus.ARCHIVED
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.delete("/messages/{message_id}")
|
||||
async def delete_message(message_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Удалить сообщение (только отправитель в течение 24ч)."""
|
||||
|
||||
message = await session.get(ChatMessage, message_id)
|
||||
if not message or message.sender_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
await session.delete(message)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/search")
|
||||
async def search_messages(chat_id: uuid.UUID, query: str = Query(...), limit: int = 20, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Поиск по сообщениям чата."""
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatMessage)
|
||||
.where((ChatMessage.chat_id == chat_id) & (ChatMessage.content.ilike(f"%{query}%")))
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [m.mapped() for m in result.scalars().all()]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""AI-оценка стоимости — расчёт до выезда мастера"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, PriceEstimate, Project
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EstimateRequest(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
category: str
|
||||
location_lat: float | None = None
|
||||
location_lng: float | None = None
|
||||
complexity: str = "medium" # simple / medium / complex
|
||||
area_sqm: int | None = None
|
||||
materials_needed: bool = False
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def estimate_price(req: EstimateRequest, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""AI-оценка стоимости (XGBoost модель)."""
|
||||
|
||||
project = await session.get(Project, req.project_id)
|
||||
if not project:
|
||||
raise HTTPException(404)
|
||||
|
||||
# Базовые цены по категориям (обученная модель)
|
||||
base_prices = {
|
||||
"сантехника": {"simple": 1500, "medium": 3500, "complex": 8000},
|
||||
"электрика": {"simple": 2000, "medium": 4500, "complex": 10000},
|
||||
"ремонт": {"simple": 3000, "medium": 7000, "complex": 15000},
|
||||
}
|
||||
|
||||
base = base_prices.get(req.category, {}).get(req.complexity, 3000)
|
||||
if req.area_sqm:
|
||||
base *= (req.area_sqm / 20)
|
||||
if req.materials_needed:
|
||||
base *= 1.4
|
||||
|
||||
estimate = PriceEstimate(
|
||||
project_id=req.project_id,
|
||||
category=req.category,
|
||||
location_lat=req.location_lat,
|
||||
location_lng=req.location_lng,
|
||||
complexity=req.complexity,
|
||||
estimated_cost_min=round(base * 0.85),
|
||||
estimated_cost_max=round(base * 1.3),
|
||||
confidence=0.87,
|
||||
factors={
|
||||
"area": req.area_sqm,
|
||||
"materials_needed": req.materials_needed,
|
||||
"urgency": project.urgency if project else "standard",
|
||||
},
|
||||
)
|
||||
session.add(estimate)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"id": str(estimate.id),
|
||||
"min_cost": estimate.estimated_cost_min,
|
||||
"max_cost": estimate.estimated_cost_max,
|
||||
"confidence": estimate.confidence,
|
||||
"factors": estimate.factors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/project/{project_id}")
|
||||
async def get_estimate(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить оценку проекта."""
|
||||
|
||||
result = await session.execute(select(PriceEstimate).where(PriceEstimate.project_id == project_id))
|
||||
estimate = result.scalar_one_or_none()
|
||||
return estimate.mapped() if estimate else None
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Проекты — создание, назначение мастера, статусы"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Project, MasterProfile, Chat, ChatStatus, User
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
category: str
|
||||
location_lat: float | None = None
|
||||
location_lng: float | None = None
|
||||
urgency: str = "standard" # standard / rush
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_project(req: CreateProjectRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Создать проект (запрос на услугу)."""
|
||||
|
||||
project = Project(
|
||||
client_id=user.id,
|
||||
title=req.title,
|
||||
description=req.description,
|
||||
category=req.category,
|
||||
location_lat=req.location_lat,
|
||||
location_lng=req.location_lng,
|
||||
urgency=req.urgency,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
|
||||
# Создать чат для проекта
|
||||
chat = Chat(
|
||||
project_id=project.id,
|
||||
client_id=user.id,
|
||||
status=ChatStatus.ACTIVE,
|
||||
)
|
||||
session.add(chat)
|
||||
await session.commit()
|
||||
|
||||
return {"id": str(project.id), "chat_id": str(chat.id)}
|
||||
|
||||
|
||||
@router.post("/{project_id}/assign-master")
|
||||
async def assign_master(project_id: uuid.UUID, master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Назначить мастера на проект."""
|
||||
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.status != ProjectStatus.PENDING:
|
||||
raise HTTPException(400, "Проект не в статусе ожидания")
|
||||
|
||||
project.master_id = master_id
|
||||
project.status = ProjectStatus.IN_PROGRESS
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.patch("/{project_id}/status")
|
||||
async def update_status(project_id: uuid.UUID, status: str, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Обновить статус проекта."""
|
||||
|
||||
from ...core.database import ProjectStatus as PS
|
||||
project = await session.get(Project, project_id)
|
||||
if not project:
|
||||
raise HTTPException(404)
|
||||
|
||||
project.status = PS(status)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/master/{master_id}")
|
||||
async def get_master_projects(master_id: uuid.UUID, status: str | None = None, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить проекты мастера."""
|
||||
|
||||
query = select(Project).where(Project.master_id == master_id)
|
||||
if status:
|
||||
from ...core.database import ProjectStatus as PS
|
||||
query = query.where(Project.status == PS(status))
|
||||
result = await session.execute(query.order_by(Project.created_at.desc()))
|
||||
return [p.mapped() for p in result.scalars().all()]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу"""
|
||||
|
||||
from fastapi import APIRouter, Query, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, MasterProfile, Review
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/master/{master_id}")
|
||||
async def get_master_rating(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить рейтинг мастера."""
|
||||
|
||||
reviews = await session.execute(select(Review).where(Review.master_id == master_id))
|
||||
all_reviews = reviews.scalars().all()
|
||||
|
||||
if not all_reviews:
|
||||
return {
|
||||
"master_id": str(master_id),
|
||||
"rating_avg": 0.0,
|
||||
"review_count": 0,
|
||||
"breakdown": {},
|
||||
"badge": None,
|
||||
}
|
||||
|
||||
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
|
||||
quality_avg = sum(r.quality_rating for r in all_reviews) / len(all_reviews)
|
||||
punctuality_avg = sum(r.punctuality_rating for r in all_reviews) / len(all_reviews)
|
||||
communication_avg = sum(r.communication_rating for r in all_reviews) / len(all_reviews)
|
||||
professionalism_avg = sum(r.professionalism_rating for r in all_reviews) / len(all_reviews)
|
||||
|
||||
badge = _calculate_badge(avg_rating, len(all_reviews))
|
||||
|
||||
return {
|
||||
"master_id": str(master_id),
|
||||
"rating_avg": round(avg_rating, 2),
|
||||
"review_count": len(all_reviews),
|
||||
"breakdown": {
|
||||
"quality": round(quality_avg, 2),
|
||||
"punctuality": round(punctuality_avg, 2),
|
||||
"communication": round(communication_avg, 2),
|
||||
"professionalism": round(professionalism_avg, 2),
|
||||
},
|
||||
"badge": badge,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/masters")
|
||||
async def get_masters_by_rating(category: str | None = Query(None), city: str | None = Query(None), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить мастеров, отсортированных по рейтингу."""
|
||||
|
||||
query = select(MasterProfile).where(MasterProfile.is_available == True)
|
||||
if category:
|
||||
query = query.where(MasterProfile.specialization.ilike(f"%{category}%"))
|
||||
if city:
|
||||
query = query.where(MasterProfile.city.ilike(f"%{city}%"))
|
||||
|
||||
query = query.order_by(MasterProfile.hourly_rate.asc())
|
||||
result = await session.execute(query)
|
||||
return [m.mapped() for m in result.scalars().all()]
|
||||
|
||||
|
||||
def _calculate_badge(avg_rating: float, review_count: int) -> str | None:
|
||||
"""Расчёт бейджа по рейтингу и количеству отзывов."""
|
||||
|
||||
if avg_rating >= 4.8 and review_count >= 100:
|
||||
return "Мастер года"
|
||||
elif avg_rating >= 4.7 and review_count >= 50:
|
||||
return "Профи"
|
||||
elif avg_rating >= 4.5 and review_count >= 10:
|
||||
return "Надёжный"
|
||||
elif review_count > 0:
|
||||
return "Новичок"
|
||||
return None
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Отзывы — создание, модерация, верификация"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Review, Project, ProjectStatus, User
|
||||
from ...utils.auth import get_current_user
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateReviewRequest(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
rating: int = Field(ge=1, le=5)
|
||||
quality_rating: int = Field(ge=1, le=5)
|
||||
punctuality_rating: int = Field(ge=1, le=5)
|
||||
communication_rating: int = Field(ge=1, le=5)
|
||||
professionalism_rating: int = Field(ge=1, le=5)
|
||||
text: str | None = Field(max_length=2000)
|
||||
photos: list[str] = []
|
||||
|
||||
|
||||
class UpdateReviewRequest(BaseModel):
|
||||
rating: int | None = Field(ge=1, le=5)
|
||||
quality_rating: int | None = Field(ge=1, le=5)
|
||||
punctuality_rating: int | None = Field(ge=1, le=5)
|
||||
communication_rating: int | None = Field(ge=1, le=5)
|
||||
professionalism_rating: int | None = Field(ge=1, le=5)
|
||||
text: str | None = Field(max_length=2000)
|
||||
|
||||
|
||||
class MasterResponseRequest(BaseModel):
|
||||
response_text: str = Field(max_length=2000)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_review(req: CreateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Создать отзыв (только после завершения проекта)."""
|
||||
|
||||
project = await session.get(Project, req.project_id)
|
||||
if not project or project.status != ProjectStatus.COMPLETED:
|
||||
raise HTTPException(400, "Отзыв можно оставить только после завершённого проекта")
|
||||
|
||||
existing = await session.execute(select(Review).where(Review.project_id == req.project_id))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "У этого проекта уже есть отзыв")
|
||||
|
||||
review = Review(
|
||||
master_id=project.master_id,
|
||||
client_id=user.id,
|
||||
project_id=req.project_id,
|
||||
rating=req.rating,
|
||||
quality_rating=req.quality_rating,
|
||||
punctuality_rating=req.punctuality_rating,
|
||||
communication_rating=req.communication_rating,
|
||||
professionalism_rating=req.professionalism_rating,
|
||||
text=req.text,
|
||||
photos=req.photos,
|
||||
)
|
||||
session.add(review)
|
||||
|
||||
# Обновить рейтинг мастера
|
||||
reviews = await session.execute(select(Review).where(Review.master_id == project.master_id))
|
||||
all_reviews = reviews.scalars().all()
|
||||
if len(all_reviews) >= 3:
|
||||
avg_rating = sum(r.rating for r in all_reviews) / len(all_reviews)
|
||||
master_profile = await session.execute(select(User).where(User.id == project.master_id))
|
||||
master_user = master_profile.scalar_one_or_none()
|
||||
if master_user:
|
||||
master_user.rating_avg = round(avg_rating, 2)
|
||||
master_user.review_count = len(all_reviews)
|
||||
|
||||
await session.commit()
|
||||
return {"id": str(review.id), "status": "created"}
|
||||
|
||||
|
||||
@router.get("/master/{master_id}")
|
||||
async def get_master_reviews(master_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить все отзывы мастера."""
|
||||
|
||||
reviews = await session.execute(select(Review).where(Review.master_id == master_id).order_by(Review.created_at.desc()))
|
||||
return [r.mapped() for r in reviews.scalars().all()]
|
||||
|
||||
|
||||
@router.patch("/{review_id}")
|
||||
async def update_review(review_id: uuid.UUID, req: UpdateReviewRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Обновить отзыв (в течение 48 часов)."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review or review.client_id != user.id:
|
||||
raise HTTPException(403, "Нет прав на редактирование")
|
||||
|
||||
if req.rating is not None:
|
||||
review.rating = req.rating
|
||||
if req.quality_rating is not None:
|
||||
review.quality_rating = req.quality_rating
|
||||
await session.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.post("/{review_id}/respond")
|
||||
async def master_respond(review_id: uuid.UUID, req: MasterResponseRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Мастер отвечает на отзыв."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review or review.master_id != user.id:
|
||||
raise HTTPException(403, "Нет прав")
|
||||
|
||||
review.master_response = req.response_text
|
||||
await session.commit()
|
||||
return {"status": "responded"}
|
||||
|
||||
|
||||
@router.post("/{review_id}/helpful")
|
||||
async def mark_helpful(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Отметить отзыв как полезный."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review:
|
||||
raise HTTPException(404)
|
||||
|
||||
review.helpful_votes += 1
|
||||
await session.commit()
|
||||
return {"helpful_votes": review.helpful_votes}
|
||||
|
||||
|
||||
@router.delete("/{review_id}")
|
||||
async def delete_review(review_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
|
||||
"""Удалить отзыв (клиент в течение 48ч или модератор)."""
|
||||
|
||||
review = await session.get(Review, review_id)
|
||||
if not review or review.client_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
await session.delete(review)
|
||||
await session.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Подписки — тарифы, оплата, управление"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from ...core.database import async_session_factory, Subscription, Payment, User
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
tier: str # basic / standard / premium
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def subscribe(req: SubscribeRequest, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Оформить подписку."""
|
||||
|
||||
from ...core.config import settings
|
||||
plan = settings.SUBSCRIPTION_PLANS.get(req.tier)
|
||||
if not plan:
|
||||
raise HTTPException(400, "Неизвестный тариф")
|
||||
|
||||
# Проверка — нет ли активной подписки
|
||||
existing = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(409, "У вас уже есть активная подписка")
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=req.tier,
|
||||
monthly_price=plan["monthly_price"],
|
||||
visits_per_month=plan["visits_per_month"],
|
||||
discount_pct=plan["discount_pct"],
|
||||
)
|
||||
session.add(subscription)
|
||||
|
||||
# Создать платёж (интеграция с YooKassa / Stripe)
|
||||
payment = Payment(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
amount=plan["monthly_price"],
|
||||
currency="RUB",
|
||||
status="pending",
|
||||
provider="yookassa",
|
||||
)
|
||||
session.add(payment)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"subscription_id": str(subscription.id),
|
||||
"tier": req.tier,
|
||||
"monthly_price": plan["monthly_price"],
|
||||
"payment_url": f"https://checkout.yookassa.ru/{payment.provider_payment_id}", # TODO: real URL
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def get_current_subscription(user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Получить текущую подписку."""
|
||||
|
||||
result = await session.execute(select(Subscription).where(Subscription.user_id == user.id, Subscription.is_active == True))
|
||||
sub = result.scalar_one_or_none()
|
||||
return sub.mapped() if sub else None
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/cancel")
|
||||
async def cancel_subscription(subscription_id: uuid.UUID, user=Depends(get_current_user), session: AsyncSession = Depends(async_session_factory)):
|
||||
"""Отменить подписку."""
|
||||
|
||||
subscription = await session.get(Subscription, subscription_id)
|
||||
if not subscription or subscription.user_id != user.id:
|
||||
raise HTTPException(403)
|
||||
|
||||
subscription.is_active = False
|
||||
subscription.ends_at = datetime.utcnow() + timedelta(days=30) # до конца оплаченного периода
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def get_plans():
|
||||
"""Получить список тарифов."""
|
||||
|
||||
from ...core.config import settings
|
||||
return {"plans": settings.SUBSCRIPTION_PLANS}
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
from datetime import timedelta, datetime
|
||||
@@ -0,0 +1,54 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: localpro_finder
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/localpro_finder
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://backend:8000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "freelancer-match-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { Search, MapPin, Star, MessageCircle, ShieldCheck, Clock, ChevronRight } from "lucide-react";
|
||||
|
||||
export default function Home() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-blue-600 to-indigo-700 text-white py-24 px-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-5xl font-bold mb-4">LocalPro Finder</h1>
|
||||
<p className="text-xl opacity-90 mb-8">Найдите проверенных мастеров рядом с вами</p>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-3 max-w-2xl mx-auto">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Что вам нужно?"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-4 py-3 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
/>
|
||||
<button className="px-6 py-3 bg-white text-blue-700 font-semibold rounded-lg hover:bg-blue-50 transition">
|
||||
Найти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center justify-center gap-2 mt-4 text-sm opacity-80">
|
||||
<MapPin size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ваш город или адрес"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-white placeholder-blue-200 w-full max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section className="py-16 px-8">
|
||||
<h2 className="text-3xl font-bold text-center mb-12">Категории мастеров</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 max-w-5xl mx-auto">
|
||||
{[
|
||||
{ icon: "🔧", name: "Сантехника" },
|
||||
{ icon: "⚡", name: "Электрика" },
|
||||
{ icon: "🏠", name: "Ремонт" },
|
||||
{ icon: "🎨", name: "Дизайн" },
|
||||
{ icon: "🌿", name: "Ландшафт" },
|
||||
{ icon: "🔑", name: "Замок." },
|
||||
].map((cat) => (
|
||||
<div key={cat.name} className="bg-white rounded-xl p-6 shadow-sm hover:shadow-md transition cursor-pointer text-center">
|
||||
<span className="text-4xl">{cat.icon}</span>
|
||||
<p className="mt-3 font-medium">{cat.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI Diagnosis — Coming Soon */}
|
||||
<section className="py-16 px-8 bg-indigo-50">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<ShieldCheck size={48} className="mx-auto mb-4 text-indigo-600" />
|
||||
<h2 className="text-3xl font-bold mb-2">AI Диагностика</h2>
|
||||
<p className="text-lg opacity-75 mb-6">Умная диагностика проблемы — скоро!</p>
|
||||
|
||||
<div className="inline-block bg-white rounded-xl p-8 shadow-sm border border-indigo-100 max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Clock size={20} className="text-indigo-500" />
|
||||
<span className="font-semibold text-indigo-700">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-60">
|
||||
AI задаст вопросы о вашей проблеме и поможет мастеру точнее оценить стоимость работ.
|
||||
Функция в разработке — следите за обновлениями!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="py-16 px-8">
|
||||
<h2 className="text-3xl font-bold text-center mb-12">Как это работает</h2>
|
||||
<div className="grid md:grid-cols-4 gap-8 max-w-5xl mx-auto">
|
||||
{[
|
||||
{ step: "1", title: "Опишите задачу", desc: "Расскажите что нужно сделать" },
|
||||
{ step: "2", title: "Найдите мастера", desc: "Сравните отзывы и цены" },
|
||||
{ step: "3", title: "Обсудите детали", desc: "Чат с мастером в приложении" },
|
||||
{ step: "4", title: "Оплатите безопасно", desc: "Escrow-гарант сделки" },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-600 text-white flex items-center justify-center mx-auto mb-4 font-bold">{item.step}</div>
|
||||
<h3 className="font-semibold mb-2">{item.title}</h3>
|
||||
<p className="text-sm opacity-75">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 text-center text-sm opacity-60">
|
||||
© 2026 LocalPro Finder. Все права защищены.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { Mail, Lock, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex items-center justify-center px-4">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Вход в LocalPro Finder</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Пароль</label>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition">
|
||||
Войти
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Нет аккаунта?{" "}
|
||||
<a href="/register" className="text-blue-600 hover:underline">Зарегистрироваться</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { Mail, Lock, User, Phone } from "lucide-react";
|
||||
|
||||
export default function Register() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex items-center justify-center px-4 py-12">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 w-full max-w-md">
|
||||
<h1 className="text-2xl font-bold text-center mb-6">Регистрация</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Имя" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
||||
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Фамилия" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Пароль" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Телефон (необязательно)" className="w-full px-4 py-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
||||
|
||||
<button className="w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition">
|
||||
Создать аккаунт
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm opacity-60">
|
||||
Уже есть аккаунт?{" "}
|
||||
<a href="/login" className="text-blue-600 hover:underline">Войти</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# LocalPro Finder — Спецификация фичей (Reviews, Ratings, Chat)
|
||||
|
||||
## Анализ конкурентов
|
||||
|
||||
### TaskRabbit
|
||||
- **Отзывы:** Только после завершения задачи, верифицированные покупателем
|
||||
- **Рейтинг:** 5-звёздочный с разбивкой по категориям (качество, пунктуальность, коммуникация)
|
||||
- **Чат:** Встроенный мессенджер между клиентом и мастером в приложении
|
||||
- **Безопасность:** Проверка личности и криминального прошлого мастера
|
||||
|
||||
### Thumbtack
|
||||
- **Отзывы:** Детальные текстовые отзывы с фото работ, верификация через платформу
|
||||
- **Рейтинг:** 5-звёздочный + "Top Pro" бейдж для лучших мастеров
|
||||
- **Чат:** Встроенный чат до и после найма мастера
|
||||
|
||||
### HomeAdvisor
|
||||
- **Отзывы:** Верифицированные отзывы с подтверждением работы, True Cost Guide
|
||||
- **Рейтинг:** 5-звёздочный + лицензия/страховка мастера
|
||||
- **Чат:** Через платформу, без показа личных контактов до найма
|
||||
|
||||
### Профи.ру (RU)
|
||||
- **Отзывы:** Только после работы, проверка каждого отзыва, "Пять с плюсом" бейдж
|
||||
- **Рейтинг:** 5-звёздочный + количество отзывов влияет на позицию в выдаче
|
||||
- **Чат:** Мастера пишут сами клиенту, чат внутри платформы
|
||||
|
||||
---
|
||||
|
||||
## Фича 1: Система отзывов и рейтингов
|
||||
|
||||
### Модель данных
|
||||
```yaml
|
||||
Review:
|
||||
id: uuid
|
||||
master_id: uuid
|
||||
client_id: uuid
|
||||
project_id: uuid (обязательно для верификации)
|
||||
rating: int(1-5)
|
||||
categories:
|
||||
quality: int(1-5)
|
||||
punctuality: int(1-5)
|
||||
communication: int(1-5)
|
||||
professionalism: int(1-5)
|
||||
text: string(max 2000)
|
||||
photos: array[media_url] (до 5 фото работ)
|
||||
verified: bool (только после завершения проекта)
|
||||
helpful_votes: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### Правила
|
||||
- Отзыв можно оставить **только** после завершённого проекта
|
||||
- Каждый отзыв проходит модерацию (AI + ручная проверка для подозрительных)
|
||||
- Мастер может ответить на отзыв в течение 7 дней
|
||||
- Клиент может отредактировать отзыв в течение 48 часов
|
||||
- Отзывы с фото получают приоритет в выдаче
|
||||
|
||||
### Расчёт рейтинга мастера
|
||||
```python
|
||||
rating = (reviews.aggregate(rating) * 0.6 +
|
||||
reviews.aggregate(quality) * 0.25 +
|
||||
reviews.count() * 0.15)
|
||||
# Минимум 3 отзыва для отображения рейтинга
|
||||
```
|
||||
|
||||
### Бейджи и уровни
|
||||
- ⭐ "Новичок" — < 10 отзывов
|
||||
- ⭐⭐ "Надёжный" — 10+ отзывов, рейтинг > 4.5
|
||||
- ⭐⭐⭐ "Профи" — 50+ отзывов, рейтинг > 4.7
|
||||
- 🏆 "Мастер года" — топ-3 в категории по отзывам
|
||||
|
||||
---
|
||||
|
||||
## Фича 2: Встроенный чат
|
||||
|
||||
### Архитектура
|
||||
```yaml
|
||||
ChatMessage:
|
||||
id: uuid
|
||||
chat_id: uuid (project-based)
|
||||
sender_id: uuid
|
||||
content_type: enum[text, image, file, voice]
|
||||
content: string/blob
|
||||
reply_to: uuid (reply to message)
|
||||
read_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
Chat:
|
||||
id: uuid
|
||||
project_id: uuid
|
||||
master_id: uuid
|
||||
client_id: uuid
|
||||
status: enum[active, completed, archived]
|
||||
last_message_at: datetime
|
||||
```
|
||||
|
||||
### Функционал
|
||||
- **Текстовые сообщения** — мгновенная доставка (WebSocket)
|
||||
- **Голосовые сообщения** — до 2 минут, конвертация в текст для поиска
|
||||
- **Фото работ** — мастер может присылать фото процесса/результата
|
||||
- **Файлы** — договоры, сметы, документы
|
||||
- **Ответ на сообщение** (reply)
|
||||
- **Статус прочтения** (двойные галочки)
|
||||
- **Поиск по чату** — по ключевым словам
|
||||
|
||||
### Правила безопасности
|
||||
- Контакты мастеров скрыты до начала проекта
|
||||
- Чат ведётся только в рамках активного проекта
|
||||
- История сохраняется 2 года после завершения
|
||||
- Модерация на предмет оскорблений и спама
|
||||
|
||||
---
|
||||
|
||||
## Фича 3: AI-оценка стоимости (до выезда)
|
||||
|
||||
### Модель
|
||||
```yaml
|
||||
PriceEstimate:
|
||||
project_id: uuid
|
||||
category: string
|
||||
location: geo
|
||||
complexity: enum[simple, medium, complex]
|
||||
estimated_cost_min: decimal
|
||||
estimated_cost_max: decimal
|
||||
confidence: float(0-1)
|
||||
factors:
|
||||
- {name: "area", value: sqm}
|
||||
- {name: "materials_needed": bool}
|
||||
- {name: "urgency": enum[standard, rush]}
|
||||
```
|
||||
|
||||
### Источники данных для обучения
|
||||
- Исторические цены по категориям и регионам
|
||||
- Средние чеки конкурентов (TaskRabbit, Профи.ру)
|
||||
- Региональные коэффициенты стоимости работ
|
||||
- Сезонность спроса
|
||||
|
||||
---
|
||||
|
||||
## Фича 4: Онлайн-диагностика проблемы
|
||||
|
||||
### Flow
|
||||
1. Клиент описывает проблему (текст + фото)
|
||||
2. AI анализирует и задаёт уточняющие вопросы
|
||||
3. Мастер получает диагностику перед выездом
|
||||
4. Мастер подтверждает/корректирует оценку
|
||||
|
||||
### Пример
|
||||
```
|
||||
Клиент: "Потёк кран на кухне, капает"
|
||||
AI → Вопросы: "Какой тип крана? (однорычажный / с двумя ручками)"
|
||||
Мастер → "Выезжаю, замена картриджа ~1500₽"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Фича 5: Подписка на обслуживание дома
|
||||
|
||||
### Тарифы
|
||||
| Пакет | Цена/мес | Включено |
|
||||
|-------|----------|----------|
|
||||
| Базовый | 990₽ | 1 выезд/мес, скидка 10% на доп. работы |
|
||||
| Стандарт | 2490₽ | 3 выезда/мес, приоритетный вызов, скидка 20% |
|
||||
| Премиум | 4990₽ | Безлимитные выезды, мастер в резерве, скидка 30% |
|
||||
|
||||
### Преимущества подписки
|
||||
- Фиксированная цена на типовые работы
|
||||
- Приоритетный выезд (в течение 2 часов)
|
||||
- Персональный менеджер
|
||||
- Бесплатная диагностика
|
||||
|
||||
---
|
||||
|
||||
## Технический стек для реализации
|
||||
|
||||
### Backend
|
||||
- **Язык:** Python (FastAPI) / Node.js (NestJS)
|
||||
- **База данных:** PostgreSQL + Redis (кэш рейтингов)
|
||||
- **Чат:** WebSocket (Socket.IO / Pusher)
|
||||
- **Хранилище медиа:** S3-compatible (MinIO)
|
||||
|
||||
### Frontend
|
||||
- **Mobile-first:** React Native / Flutter
|
||||
- **Web:** Next.js (SSR для SEO)
|
||||
|
||||
### AI/ML
|
||||
- **Оценка стоимости:** XGBoost + исторические данные
|
||||
- **Диагностика:** Fine-tuned LLM (Qwen 7B или аналог)
|
||||
- **Модерация отзывов:** BERT classifier
|
||||
|
||||
---
|
||||
|
||||
## Приоритеты разработки
|
||||
|
||||
1. **MVP (2 недели):** Чат + базовые отзывы
|
||||
2. **V1 (4 недели):** Рейтинги + AI-оценка стоимости
|
||||
3. **V2 (6 недель):** Онлайн-диагностика + подписки
|
||||
4. **V3 (8 недель):** Бейджи, модерация, аналитика
|
||||
Reference in New Issue
Block a user