feat: LocalPro Finder — продакшн проект (отзывы, рейтинги, чат, AI-оценка, диагностика, подписки)

This commit is contained in:
2026-07-03 14:39:56 +00:00
commit d85c2051b9
22 changed files with 1390 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# LocalPro Finder — Production .env
APP_NAME="LocalPro Finder"
APP_VERSION="1.0.0"
DEBUG=false
SECRET_KEY="a3f8b2c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"
# PostgreSQL
DATABASE_URL="postgresql+asyncpg://postgres:localpro_secure_pass_2026@db:5432/localpro"
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
# Redis
REDIS_URL="redis://redis:6379/0"
# JWT
ACCESS_TOKEN_EXPIRE_MINUTES=1440
REFRESH_TOKEN_EXPIRE_DAYS=30
# AI / ML (оценка стоимости + диагностика)
AI_MODEL_NAME="qwen-7b"
AI_API_URL=""
AI_API_KEY=""
# Storage (S3/MinIO)
STORAGE_PROVIDER="s3"
S3_BUCKET_NAME="localpro-media"
S3_REGION="us-east-1"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
# Email
SMTP_HOST="smtp.gmail.com"
SMTP_PORT=587
SMTP_USER=""
SMTP_PASSWORD=""
# Telegram Bot (уведомления)
TELEGRAM_BOT_TOKEN=""
TELEGRAM_ADMIN_CHAT_ID="0"
# Модерация отзывов
REVIEW_MODERATION_AI_ENABLED=true
REVIEW_MIN_REVIEWS_FOR_RATING=3
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
+10
View File
@@ -0,0 +1,10 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.37
asyncpg==0.30.0
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
websockets==14.1
boto3==1.36.18
aioredis==2.0.1
+22
View File
@@ -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"])
+107
View File
@@ -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,
}
+124
View File
@@ -0,0 +1,124 @@
"""Чат — WebSocket + REST для сообщений"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from ...core.database import async_session_factory, Chat, ChatMessage, ContentType, ChatStatus
from sqlalchemy import update
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)
# Обновить last_message_at
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()]
+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
+79
View File
@@ -0,0 +1,79 @@
"""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 sqlalchemy import select
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
+87
View File
@@ -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()]
+80
View File
@@ -0,0 +1,80 @@
"""Рейтинги — расчёт, бейджи, выдача мастеров по рейтингу"""
from fastapi import APIRouter, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from ...core.database import async_session_factory, MasterProfile, Review
from fastapi import Depends
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()) # TODO: join reviews for rating sort
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
+142
View File
@@ -0,0 +1,142 @@
"""Отзывы — создание, модерация, верификация"""
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"}
+92
View File
@@ -0,0 +1,92 @@
"""Подписки — тарифы, оплата, управление"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import uuid
from datetime import timedelta, datetime
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}
+65
View File
@@ -0,0 +1,65 @@
"""Конфигурация приложения — загружается из .env"""
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# === Базовые настройки ===
APP_NAME: str = "LocalPro Finder"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
# === База данных (PostgreSQL) ===
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://postgres:password@db:5432/localpro"
)
DB_POOL_SIZE: int = 10
DB_MAX_OVERFLOW: int = 20
# === Redis (кэш, сессии, WebSocket) ===
REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0")
# === JWT-токены ===
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 часа
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# === AI / ML (оценка стоимости + диагностика) ===
AI_MODEL_NAME: str = os.getenv("AI_MODEL_NAME", "qwen-7b")
AI_API_URL: str = os.getenv("AI_API_URL", "")
AI_API_KEY: str = os.getenv("AI_API_KEY", "")
# === Хранилище медиа (S3 / MinIO) ===
STORAGE_PROVIDER: str = os.getenv("STORAGE_PROVIDER", "s3") # s3 | minio | local
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "localpro-media")
S3_REGION: str = os.getenv("S3_REGION", "us-east-1")
S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "")
S3_SECRET_KEY: str = os.getenv("S3_SECRET_KEY", "")
# === Email (уведомления) ===
SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT: int = 587
SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
# === Telegram-бот (уведомления) ===
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_ADMIN_CHAT_ID: int = int(os.getenv("TELEGRAM_ADMIN_CHAT_ID", "0"))
# === Модерация отзывов ===
REVIEW_MODERATION_AI_ENABLED: bool = True
REVIEW_MIN_REVIEWS_FOR_RATING: int = 3
# === Подписки (тарифы) ===
SUBSCRIPTION_PLANS: dict = {
"basic": {"monthly_price": 990, "visits_per_month": 1, "discount_pct": 10},
"standard": {"monthly_price": 2490, "visits_per_month": 3, "discount_pct": 20},
"premium": {"monthly_price": 4990, "visits_per_month": -1, "discount_pct": 30},
}
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
+207
View File
@@ -0,0 +1,207 @@
"""SQLAlchemy 2.0 async-движок + модели БД"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, JSON, Enum as SAEnum
from sqlalchemy.dialects.postgresql import UUID
import uuid
import enum
from datetime import datetime
from .config import settings
# === Engine & Session ===
engine = create_async_engine(settings.DATABASE_URL, pool_size=settings.DB_POOL_SIZE)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(MappedAsDataclass, DeclarativeBase):
pass
# === Enums ===
class UserRole(str, enum.Enum):
CLIENT = "client"
MASTER = "master"
ADMIN = "admin"
class ProjectStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class ChatStatus(str, enum.Enum):
ACTIVE = "active"
COMPLETED = "completed"
ARCHIVED = "archived"
class ContentType(str, enum.Enum):
TEXT = "text"
IMAGE = "image"
FILE = "file"
VOICE = "voice"
# === Models ===
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = Column(String(255), unique=True, nullable=False, index=True)
phone: Mapped[str] = Column(String(20), nullable=True)
password_hash: Mapped[str] = Column(String(128), nullable=False)
role: Mapped[UserRole] = Column(SAEnum(UserRole), default=UserRole.CLIENT)
first_name: Mapped[str] = Column(String(100))
last_name: Mapped[str] = Column(String(100))
avatar_url: Mapped[str | None] = Column(String(500))
is_verified: Mapped[bool] = Column(Boolean, default=False) # паспорт/документы
rating_avg: Mapped[float] = Column(Float, default=0.0)
review_count: Mapped[int] = Column(Integer, default=0)
badges: Mapped[list[str]] = Column(JSON, default=list)
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class MasterProfile(Base):
__tablename__ = "master_profiles"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"), unique=True)
specialization: Mapped[str] = Column(String(100)) # сантехник, электрик и т.д.
description: Mapped[str | None] = Column(Text)
experience_years: Mapped[int] = Column(Integer, default=0)
hourly_rate: Mapped[float] = Column(Float)
city: Mapped[str] = Column(String(100))
region: Mapped[str] = Column(String(100))
is_available: Mapped[bool] = Column(Boolean, default=True)
subscription_tier: Mapped[str | None] = Column(String(20)) # basic/standard/premium
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
master_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id"))
title: Mapped[str] = Column(String(200))
description: Mapped[str] = Column(Text)
category: Mapped[str] = Column(String(100)) # сантехника, электрика и т.д.
status: Mapped[ProjectStatus] = Column(SAEnum(ProjectStatus), default=ProjectStatus.PENDING)
estimated_cost_min: Mapped[float | None] = Column(Float)
estimated_cost_max: Mapped[float | None] = Column(Float)
actual_cost: Mapped[float | None] = Column(Float)
location_lat: Mapped[float | None] = Column(Float)
location_lng: Mapped[float | None] = Column(Float)
urgency: Mapped[str] = Column(String(20), default="standard") # standard/rush
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Review(Base):
__tablename__ = "reviews"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
master_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id"))
client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
rating: Mapped[int] = Column(Integer, default=5) # 1-5
quality_rating: Mapped[int] = Column(Integer, default=5)
punctuality_rating: Mapped[int] = Column(Integer, default=5)
communication_rating: Mapped[int] = Column(Integer, default=5)
professionalism_rating: Mapped[int] = Column(Integer, default=5)
text: Mapped[str | None] = Column(Text)
photos: Mapped[list[str]] = Column(JSON, default=list) # media URLs
verified: Mapped[bool] = Column(Boolean, default=True)
helpful_votes: Mapped[int] = Column(Integer, default=0)
master_response: Mapped[str | None] = Column(Text)
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
class Chat(Base):
__tablename__ = "chats"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
master_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("master_profiles.user_id"))
client_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
status: Mapped[ChatStatus] = Column(SAEnum(ChatStatus), default=ChatStatus.ACTIVE)
last_message_at: Mapped[datetime | None] = Column(DateTime)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
chat_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("chats.id"))
sender_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True))
content_type: Mapped[ContentType] = Column(SAEnum(ContentType), default=ContentType.TEXT)
content: Mapped[str | None] = Column(Text) # text or transcription of voice
media_url: Mapped[str | None] = Column(String(500)) # image/file URL
reply_to_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True), ForeignKey("chat_messages.id"))
read_at: Mapped[datetime | None] = Column(DateTime)
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
class PriceEstimate(Base):
__tablename__ = "price_estimates"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
category: Mapped[str] = Column(String(100))
location_lat: Mapped[float | None] = Column(Float)
location_lng: Mapped[float | None] = Column(Float)
complexity: Mapped[str] = Column(String(20), default="medium") # simple/medium/complex
estimated_cost_min: Mapped[float] = Column(Float)
estimated_cost_max: Mapped[float] = Column(Float)
confidence: Mapped[float] = Column(Float, default=0.85)
factors: Mapped[dict] = Column(JSON) # {area, materials_needed, urgency}
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
class Diagnosis(Base):
__tablename__ = "diagnoses"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("projects.id"), unique=True)
problem_description: Mapped[str] = Column(Text)
photos: Mapped[list[str]] = Column(JSON, default=list)
ai_questions: Mapped[list[str]] = Column(JSON, default=list) # уточняющие вопросы от AI
client_answers: Mapped[dict | None] = Column(JSON)
diagnosis_result: Mapped[str | None] = Column(Text)
master_confirmation: Mapped[str | None] = Column(Text)
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
class Subscription(Base):
__tablename__ = "subscriptions"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
tier: Mapped[str] = Column(String(20)) # basic/standard/premium
monthly_price: Mapped[float] = Column(Float)
visits_per_month: Mapped[int] = Column(Integer)
discount_pct: Mapped[int] = Column(Integer)
starts_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
ends_at: Mapped[datetime | None] = Column(DateTime)
is_active: Mapped[bool] = Column(Boolean, default=True)
class Payment(Base):
__tablename__ = "payments"
id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True))
subscription_id: Mapped[uuid.UUID | None] = Column(UUID(as_uuid=True))
user_id: Mapped[uuid.UUID] = Column(UUID(as_uuid=True), ForeignKey("users.id"))
amount: Mapped[float] = Column(Float)
currency: Mapped[str] = Column(String(3), default="RUB")
status: Mapped[str] = Column(String(20), default="pending") # pending/success/failed/refunded
provider: Mapped[str | None] = Column(String(50)) # stripe/yookassa
provider_payment_id: Mapped[str | None] = Column(String(100))
created_at: Mapped[datetime] = Column(DateTime, default=datetime.utcnow)
+50
View File
@@ -0,0 +1,50 @@
"""LocalPro Finder — FastAPI приложение"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .core.config import settings
from .api.routes import router as api_router
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS для фронтенда
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: restrict in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.websocket("/ws/{project_id}")
async def websocket_endpoint(websocket: WebSocket, project_id: str):
"""WebSocket для чата в реальном времени."""
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
# TODO: broadcast to chat participants
await websocket.send_text(data)
except WebSocketDisconnect:
pass
@app.get("/health")
async def health():
return {"status": "ok", "version": settings.APP_VERSION}
if __name__ == "__main__":
uvicorn.run("src.main:app", host="0.0.0.0", port=8000, reload=True)
View File
+1
View File
@@ -0,0 +1 @@
# utils package
+60
View File
@@ -0,0 +1,60 @@
"""JWT-токены и хеширование паролей"""
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt
from datetime import datetime, timedelta
from ..core.config import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def create_access_token(user_id: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": user_id, "exp": expire}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def create_refresh_token(user_id: str) -> str:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": user_id, "exp": expire}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Токен истёк")
except jwt.InvalidTokenError:
raise HTTPException(401, "Неверный токен")
def decode_refresh_token(token: str) -> str:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
return payload["sub"]
async def get_current_user(token=Depends(oauth2_scheme)):
"""Получить текущего пользователя из токена."""
payload = decode_access_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(401, "Неверный токен")
return {"id": user_id}
def hash_password(password: str) -> str:
"""Хеширование пароля (bcrypt)."""
import bcrypt
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode(), salt).decode()
def verify_password(plain: str, hashed: str) -> bool:
import bcrypt
return bcrypt.checkpw(plain.encode(), hashed.encode())
+30
View File
@@ -0,0 +1,30 @@
version: "3.9"
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: localpro
POSTGRES_USER: postgres
POSTGRES_PASSWORD: localpro_secure_pass_2026
ports: ["5432:5432"]
volumes: [db-data:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
backend:
build: ./backend
ports: ["8000:8000"]
env_file: [./backend/.env]
depends_on: [db, redis]
frontend:
build: ./frontend
ports: ["3000:3000"]
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
volumes:
db-data:
+6
View File
@@ -0,0 +1,6 @@
# LocalPro Finder — Frontend .env
NEXT_PUBLIC_API_URL="http://localhost:8000/api/v1"
NEXT_PUBLIC_WS_URL="ws://localhost:8000/ws"
NEXT_PUBLIC_APP_NAME="LocalPro Finder"
NEXT_PUBLIC_VERSION="1.0.0"
+23
View File
@@ -0,0 +1,23 @@
{
"name": "localpro-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"axios": "^1.7.9",
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"next": "^15.0.3",
"typescript": "^5.6.3"
}
}
+77
View File
@@ -0,0 +1,77 @@
import React from 'react'
import { Star, MessageCircle, Clock, ShieldCheck } from 'lucide-react'
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
{/* Hero */}
<section className="py-20 px-4 text-center">
<h1 className="text-5xl font-bold mb-6">Мастер за час</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8">
Найдите проверенного мастера за 3 минуты. AI-оценка стоимости, реальные отзывы, гарантия качества.
</p>
<button className="bg-blue-600 text-white px-8 py-4 rounded-xl text-lg font-semibold hover:bg-blue-700 transition">
Найти мастера
</button>
</section>
{/* Features */}
<section className="py-16 px-4 max-w-5xl mx-auto grid md:grid-cols-2 gap-8">
{[
{ icon: Star, title: 'Верифицированные отзывы', desc: 'Только после работы. AI-модерация.' },
{ icon: MessageCircle, title: 'Чат с мастером', desc: 'Голосовые сообщения и фото процесса.' },
{ icon: Clock, title: 'AI-оценка стоимости', desc: 'Узнайте цену до выезда мастера.' },
{ icon: ShieldCheck, title: 'Подписка на обслуживание', desc: 'Базовый / Стандарт / Премиум.' },
].map((f) => (
<div key={f.title} className="p-6 rounded-xl bg-white shadow-sm">
<f.icon className="w-8 h-8 text-blue-600 mb-4" />
<h3 className="text-lg font-semibold mb-2">{f.title}</h3>
<p className="text-gray-500">{f.desc}</p>
</div>
))}
</section>
{/* Reviews */}
<section className="py-16 px-4 max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-8">Отзывы клиентов</h2>
{[
{ name: 'Ирина', rating: 5, text: 'Дмитрий прислал подробный перечень работ с ценами. Одно из самых выгодных предложений!' },
{ name: 'Марина', rating: 4, text: 'Сделал всё быстро и профессионально, учёл все пожелания.' },
].map((r) => (
<div key={r.name} className="mb-6 p-6 rounded-xl bg-white shadow-sm">
<div className="flex items-center mb-2">
{[...Array(r.rating)].map((_, i) => <Star key={i} className="w-4 h-4 text-yellow-500 fill-current" />)}
<span className="ml-3 font-semibold">{r.name}</span>
</div>
<p className="text-gray-600">{r.text}</p>
</div>
))}
</section>
{/* Pricing */}
<section className="py-16 px-4 max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-8">Подписка на обслуживание</h2>
<div className="grid md:grid-cols-3 gap-6">
{[
{ name: 'Базовый', price: 990, visits: 1, discount: 10 },
{ name: 'Стандарт', price: 2490, visits: 3, discount: 20 },
{ name: 'Премиум', price: 4990, visits: -1, discount: 30 },
].map((p) => (
<div key={p.name} className="p-6 rounded-xl bg-white shadow-sm text-center">
<h3 className="text-lg font-semibold mb-2">{p.name}</h3>
<p className="text-3xl font-bold mb-4">{p.price}/мес</p>
<ul className="space-y-2 text-gray-600">
{p.visits > 0 && <li>{p.visits} выезда в месяц</li>}
{p.visits === -1 && <li>Безлимитные выезды</li>}
<li>Скидка на доп. работы: {p.discount}%</li>
</ul>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="py-8 text-center text-gray-400">© 2026 LocalPro Finder. Все права защищены.</footer>
</div>
)
}