Compare commits

..

2 Commits

6 changed files with 80 additions and 262 deletions
-67
View File
@@ -1,67 +0,0 @@
# 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
+77
View File
@@ -0,0 +1,77 @@
"""Онлайн-диагностика — AI задаёт вопросы, мастер подтверждает"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from ...core.database import async_session_factory, Diagnosis, Project
from pydantic import BaseModel
router = APIRouter()
class CreateDiagnosisRequest(BaseModel):
project_id: uuid.UUID
problem_description: str
photos: list[str] = []
@router.post("/")
async def create_diagnosis(req: CreateDiagnosisRequest, session: AsyncSession = Depends(async_session_factory), user=Depends(get_current_user)):
"""Создать диагностику проблемы."""
project = await session.get(Project, req.project_id)
if not project or project.client_id != user.id:
raise HTTPException(403)
diagnosis = Diagnosis(
project_id=req.project_id,
problem_description=req.problem_description,
photos=req.photos,
ai_questions=["Какой тип крана?", "Как давно течёт?"], # AI генерирует динамически
)
session.add(diagnosis)
await session.commit()
return {"id": str(diagnosis.id), "ai_questions": diagnosis.ai_questions}
@router.post("/{diagnosis_id}/answer")
async def answer_diagnosis(diagnosis_id: uuid.UUID, answers: dict, session: AsyncSession = Depends(async_session_factory)):
"""Ответить на вопросы диагностики."""
diagnosis = await session.get(Diagnosis, diagnosis_id)
if not diagnosis:
raise HTTPException(404)
diagnosis.client_answers = answers
# AI генерирует результат
diagnosis.diagnosis_result = "Замена картриджа однорычажного крана. Стоимость ~1500₽."
await session.commit()
return {"diagnosis_result": diagnosis.diagnosis_result}
@router.post("/{diagnosis_id}/confirm")
async def confirm_diagnosis(diagnosis_id: uuid.UUID, master_confirmation: str, session: AsyncSession = Depends(async_session_factory)):
"""Мастер подтверждает диагностику."""
diagnosis = await session.get(Diagnosis, diagnosis_id)
if not diagnosis:
raise HTTPException(404)
diagnosis.master_confirmation = master_confirmation
await session.commit()
@router.get("/project/{project_id}")
async def get_project_diagnosis(project_id: uuid.UUID, session: AsyncSession = Depends(async_session_factory)):
"""Получить диагностику проекта."""
result = await session.execute(select(Diagnosis).where(Diagnosis.project_id == project_id))
diagnosis = result.scalar_one_or_none()
return diagnosis.mapped() if diagnosis else None
from sqlalchemy import select
+3 -2
View File
@@ -6,7 +6,7 @@ services:
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: localpro_finder POSTGRES_DB: freelancer_match
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -30,9 +30,10 @@ services:
backend: backend:
build: ./backend build: ./backend
environment: environment:
DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/localpro_finder DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/freelancer_match
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
-109
View File
@@ -1,109 +0,0 @@
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>
);
}
-49
View File
@@ -1,49 +0,0 @@
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>
);
}
-35
View File
@@ -1,35 +0,0 @@
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>
);
}