Compare commits
2 Commits
main
..
81c755ec3a
| Author | SHA1 | Date | |
|---|---|---|---|
| 81c755ec3a | |||
| ef711d461f |
@@ -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
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user