commit f50174f8980490e8d1d9246521b4fcb5795ec92c Author: Sergio Correa Date: Sat Nov 22 02:33:15 2025 +0000 feat: Implementar funcionalidades de Tarefas e Saúde - Criadas APIs para Health (métricas de saúde) * Registrar peso, altura, % gordura, medidas * Histórico completo de medições * Estatísticas e resumo - Criadas APIs para Tasks (tarefas) * Criar, editar e deletar tarefas * Filtros por status e data * Estatísticas detalhadas * Prioridades (baixa, média, alta) - Frontend implementado: * Página Health.tsx - registro de métricas * Página Tasks.tsx - gerenciamento de tarefas * Página Progress.tsx - visualização de progresso * Dashboard integrado com estatísticas reais - Schemas e modelos atualizados - Todas as funcionalidades testadas e operacionais diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c951d32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Backend +backend/__pycache__/ +backend/**/__pycache__/ +backend/**/**/__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +.env + +# Frontend +frontend/node_modules/ +frontend/build/ +frontend/.env +frontend/.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docker +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2c65248 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,18 @@ +# Database +DATABASE_URL=postgresql://vida180_user:vida180_strong_password_2024@postgres:5432/vida180_db + +# Redis +REDIS_URL=redis://:vida180_redis_pass_2024@redis:6379/0 + +# JWT +SECRET_KEY=change-this-to-random-secret-key-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# App +API_V1_PREFIX=/api/v1 +PROJECT_NAME=Vida180 +DEBUG=True + +# CORS +BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost","http://vida180.com.br","https://vida180.com.br"] diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2225ed3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e8aed15 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,4 @@ +from . import auth, habits, health, messages, admin, tasks + +__all__ = ['auth', 'habits', 'health', 'messages', 'admin', 'tasks'] + diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..490cc4f --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,148 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime + +from app.core.database import get_db +from app.core.security import decode_access_token, get_password_hash +from app.models.user import User +from app.schemas.admin import UserListResponse, UserUpdateRequest, PasswordChangeRequest + +router = APIRouter(prefix="/admin", tags=["admin"]) +security = HTTPBearer() + +def get_current_superadmin( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Verifica se usuário é superadmin""" + token = credentials.credentials + payload = decode_access_token(token) + if not payload: + raise HTTPException(status_code=401, detail="Token inválido") + + user_id = payload.get("sub") + user = db.query(User).filter(User.id == user_id).first() + + if not user or not user.is_superadmin: + raise HTTPException( + status_code=403, + detail="Acesso negado. Apenas superadmin." + ) + + return user + +@router.get("/users", response_model=List[UserListResponse]) +async def list_all_users( + admin: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """Listar todos os usuários (apenas superadmin)""" + users = db.query(User).order_by(User.created_at.desc()).all() + return users + +@router.get("/stats") +async def get_admin_stats( + admin: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """Estatísticas gerais do sistema""" + from sqlalchemy import func + from app.models.habit import Habit, HabitCompletion + + total_users = db.query(func.count(User.id)).scalar() + active_users = db.query(func.count(User.id)).filter(User.is_active == True).scalar() + total_habits = db.query(func.count(Habit.id)).scalar() + total_completions = db.query(func.count(HabitCompletion.id)).scalar() + + return { + "total_users": total_users, + "active_users": active_users, + "inactive_users": total_users - active_users, + "total_habits": total_habits, + "total_completions": total_completions + } + +@router.patch("/users/{user_id}") +async def update_user( + user_id: str, + data: UserUpdateRequest, + admin: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """Atualizar dados de usuário""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="Usuário não encontrado") + + # Atualizar campos + if data.email is not None: + # Verificar se email já existe + existing = db.query(User).filter( + User.email == data.email, + User.id != user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="Email já em uso") + user.email = data.email + + if data.username is not None: + # Verificar se username já existe + existing = db.query(User).filter( + User.username == data.username, + User.id != user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="Username já em uso") + user.username = data.username + + if data.full_name is not None: + user.full_name = data.full_name + + if data.is_active is not None: + user.is_active = data.is_active + + if data.is_verified is not None: + user.is_verified = data.is_verified + + db.commit() + db.refresh(user) + + return {"message": "Usuário atualizado", "user": user} + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: str, + admin: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """Deletar usuário (soft delete - apenas desativa)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="Usuário não encontrado") + + if user.is_superadmin: + raise HTTPException(status_code=403, detail="Não é possível deletar superadmin") + + user.is_active = False + db.commit() + + return {"message": "Usuário desativado com sucesso"} + +@router.post("/users/{user_id}/reset-password") +async def reset_user_password( + user_id: str, + data: PasswordChangeRequest, + admin: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """Resetar senha de usuário""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="Usuário não encontrado") + + user.password_hash = get_password_hash(data.new_password) + db.commit() + + return {"message": "Senha alterada com sucesso"} diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..f33fa37 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.security import get_password_hash, verify_password, create_access_token +from app.models.user import User +from app.schemas.user import UserCreate, UserLogin, Token, UserResponse +from datetime import datetime + +router = APIRouter() + +@router.post("/register", response_model=Token) +def register(user: UserCreate, db: Session = Depends(get_db)): + if db.query(User).filter(User.email == user.email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + + if db.query(User).filter(User.username == user.username).first(): + raise HTTPException(status_code=400, detail="Username already taken") + + db_user = User( + email=user.email, + username=user.username, + password_hash=get_password_hash(user.password), + full_name=user.full_name, + phone=user.phone, + is_verified=True # SEM verificação de email + ) + + db.add(db_user) + db.commit() + db.refresh(db_user) + + access_token = create_access_token(data={"sub": db_user.email}) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": UserResponse.from_orm(db_user) + } + +@router.post("/login", response_model=Token) +def login(credentials: UserLogin, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == credentials.email).first() + + if not user or not verify_password(credentials.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + + user.last_login_at = datetime.utcnow() + db.commit() + + access_token = create_access_token(data={"sub": user.email}) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": UserResponse.from_orm(user) + } diff --git a/backend/app/api/habits.py b/backend/app/api/habits.py new file mode 100644 index 0000000..4409456 --- /dev/null +++ b/backend/app/api/habits.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from typing import List +from datetime import date, datetime + +from app.core.database import get_db +from app.core.security import decode_access_token +from app.models.user import User +from app.models.habit import Habit, HabitCompletion +from app.schemas.habit import HabitCreate, HabitResponse, HabitCompletionCreate + +router = APIRouter(prefix="/habits", tags=["habits"]) +security = HTTPBearer() + +def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: + """Extrai user_id do token JWT""" + token = credentials.credentials + payload = decode_access_token(token) + if not payload: + raise HTTPException(status_code=401, detail="Token inválido") + return payload.get("sub") + +@router.post("/", response_model=HabitResponse, status_code=status.HTTP_201_CREATED) +async def create_habit( + habit_data: HabitCreate, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Criar novo hábito""" + new_habit = Habit( + user_id=user_id, + name=habit_data.name, + description=habit_data.description, + frequency_type=habit_data.frequency_type, + target_count=habit_data.target_count, + reminder_time=habit_data.reminder_time, + start_date=habit_data.start_date or date.today() + ) + + db.add(new_habit) + db.commit() + db.refresh(new_habit) + + return new_habit + +@router.get("/", response_model=List[HabitResponse]) +async def list_habits( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Listar todos os hábitos do usuário""" + habits = db.query(Habit).filter( + Habit.user_id == user_id, + Habit.is_active == True + ).all() + + return habits + +@router.post("/{habit_id}/complete", status_code=status.HTTP_201_CREATED) +async def complete_habit( + habit_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Marcar hábito como completo para hoje""" + # Verificar se já completou hoje + today = date.today() + existing = db.query(HabitCompletion).filter( + HabitCompletion.habit_id == habit_id, + HabitCompletion.user_id == user_id, + HabitCompletion.completion_date == today + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Hábito já completado hoje") + + # Criar completion + completion = HabitCompletion( + habit_id=habit_id, + user_id=user_id, + completion_date=today, + completion_time=datetime.now().time() + ) + + db.add(completion) + db.commit() + + return {"message": "Hábito completado!", "date": today} + +@router.delete("/{habit_id}/complete") +async def uncomplete_habit( + habit_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Desmarcar hábito de hoje""" + today = date.today() + completion = db.query(HabitCompletion).filter( + HabitCompletion.habit_id == habit_id, + HabitCompletion.user_id == user_id, + HabitCompletion.completion_date == today + ).first() + + if not completion: + raise HTTPException(status_code=404, detail="Completion não encontrado") + + db.delete(completion) + db.commit() + + return {"message": "Completion removido"} + +@router.get("/stats") +async def get_habit_stats( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Estatísticas de hábitos""" + from sqlalchemy import func + + total_habits = db.query(func.count(Habit.id)).filter( + Habit.user_id == user_id, + Habit.is_active == True + ).scalar() + + completed_today = db.query(func.count(HabitCompletion.id)).filter( + HabitCompletion.user_id == user_id, + HabitCompletion.completion_date == date.today() + ).scalar() + + return { + "total_habits": total_habits, + "completed_today": completed_today, + "pending_today": total_habits - completed_today + } + +@router.delete("/{habit_id}") +async def delete_habit( + habit_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Deletar hábito""" + habit = db.query(Habit).filter( + Habit.id == habit_id, + Habit.user_id == user_id + ).first() + + if not habit: + raise HTTPException(status_code=404, detail="Hábito não encontrado") + + db.delete(habit) + db.commit() + + return {"message": "Hábito deletado"} diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..5a54066 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,169 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from typing import List +from datetime import date + +from app.core.database import get_db +from app.core.security import decode_access_token +from app.models.health import HealthMetric +from app.schemas.health import HealthMetricCreate, HealthMetricResponse, HealthMetricUpdate + +router = APIRouter() +security = HTTPBearer() + +def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: + """Extrai user_id do token JWT""" + token = credentials.credentials + payload = decode_access_token(token) + if not payload: + raise HTTPException(status_code=401, detail="Token inválido") + return payload.get("sub") + +@router.post("/", response_model=HealthMetricResponse, status_code=status.HTTP_201_CREATED) +async def create_health_metric( + metric_data: HealthMetricCreate, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Criar nova métrica de saúde""" + new_metric = HealthMetric( + user_id=user_id, + measurement_date=metric_data.measurement_date, + measurement_time=metric_data.measurement_time, + weight=metric_data.weight, + height=metric_data.height, + body_fat_percentage=metric_data.body_fat_percentage, + muscle_mass=metric_data.muscle_mass, + waist=metric_data.waist, + chest=metric_data.chest, + hips=metric_data.hips, + notes=metric_data.notes + ) + + db.add(new_metric) + db.commit() + db.refresh(new_metric) + + return new_metric + +@router.get("/", response_model=List[HealthMetricResponse]) +async def list_health_metrics( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db), + limit: int = 100 +): + """Listar todas as métricas de saúde do usuário""" + metrics = db.query(HealthMetric).filter( + HealthMetric.user_id == user_id + ).order_by(HealthMetric.measurement_date.desc()).limit(limit).all() + + return metrics + +@router.get("/latest", response_model=HealthMetricResponse) +async def get_latest_metric( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Obter última métrica registrada""" + metric = db.query(HealthMetric).filter( + HealthMetric.user_id == user_id + ).order_by(HealthMetric.measurement_date.desc()).first() + + if not metric: + raise HTTPException(status_code=404, detail="Nenhuma métrica encontrada") + + return metric + +@router.get("/{metric_id}", response_model=HealthMetricResponse) +async def get_health_metric( + metric_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Obter métrica específica""" + metric = db.query(HealthMetric).filter( + HealthMetric.id == metric_id, + HealthMetric.user_id == user_id + ).first() + + if not metric: + raise HTTPException(status_code=404, detail="Métrica não encontrada") + + return metric + +@router.put("/{metric_id}", response_model=HealthMetricResponse) +async def update_health_metric( + metric_id: str, + metric_data: HealthMetricUpdate, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Atualizar métrica de saúde""" + metric = db.query(HealthMetric).filter( + HealthMetric.id == metric_id, + HealthMetric.user_id == user_id + ).first() + + if not metric: + raise HTTPException(status_code=404, detail="Métrica não encontrada") + + # Atualizar campos + update_data = metric_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(metric, field, value) + + db.commit() + db.refresh(metric) + + return metric + +@router.delete("/{metric_id}") +async def delete_health_metric( + metric_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Deletar métrica de saúde""" + metric = db.query(HealthMetric).filter( + HealthMetric.id == metric_id, + HealthMetric.user_id == user_id + ).first() + + if not metric: + raise HTTPException(status_code=404, detail="Métrica não encontrada") + + db.delete(metric) + db.commit() + + return {"message": "Métrica deletada com sucesso"} + +@router.get("/stats/summary") +async def get_health_stats( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Estatísticas e resumo de saúde""" + latest = db.query(HealthMetric).filter( + HealthMetric.user_id == user_id + ).order_by(HealthMetric.measurement_date.desc()).first() + + # Pegar primeira e última medição para calcular diferença + first = db.query(HealthMetric).filter( + HealthMetric.user_id == user_id, + HealthMetric.weight.isnot(None) + ).order_by(HealthMetric.measurement_date.asc()).first() + + stats = { + "current_weight": float(latest.weight) if latest and latest.weight else None, + "current_height": float(latest.height) if latest and latest.height else None, + "current_body_fat": float(latest.body_fat_percentage) if latest and latest.body_fat_percentage else None, + "weight_change": None, + "total_measurements": db.query(HealthMetric).filter(HealthMetric.user_id == user_id).count() + } + + if latest and first and latest.weight and first.weight: + stats["weight_change"] = float(latest.weight - first.weight) + + return stats + diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py new file mode 100644 index 0000000..34dfcee --- /dev/null +++ b/backend/app/api/messages.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Dict + +from app.core.database import get_db +from app.services.message_service import MessageService + +router = APIRouter(prefix="/messages", tags=["messages"]) + + +@router.get("/daily", response_model=Dict) +async def get_daily_message( + user_id: str, # TODO: Pegar do JWT token quando implementar autenticação + db: Session = Depends(get_db) +): + """ + Retorna a mensagem motivacional do dia para o usuário. + A mensagem é contextual baseada em: + - Streaks e conquistas + - Inatividade + - Lembretes + - Progresso + - Hora do dia + """ + try: + message_service = MessageService(db) + message = message_service.get_message_of_the_day(user_id) + + return { + "success": True, + "message": message + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/click/{message_id}") +async def mark_message_clicked( + message_id: str, + user_id: str, + db: Session = Depends(get_db) +): + """Marca mensagem como clicada pelo usuário""" + from app.models.message import UserMessageLog + from datetime import datetime + + try: + log = db.query(UserMessageLog).filter( + UserMessageLog.id == message_id, + UserMessageLog.user_id == user_id + ).first() + + if log: + log.was_clicked = True + log.clicked_at = datetime.now() + db.commit() + + return {"success": True, "message": "Click registrado"} + else: + raise HTTPException(status_code=404, detail="Mensagem não encontrada") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/history") +async def get_message_history( + user_id: str, + limit: int = 10, + db: Session = Depends(get_db) +): + """Retorna histórico de mensagens do usuário""" + from app.models.message import UserMessageLog + + try: + logs = db.query(UserMessageLog).filter( + UserMessageLog.user_id == user_id + ).order_by(UserMessageLog.shown_at.desc()).limit(limit).all() + + return { + "success": True, + "messages": [ + { + "id": str(log.id), + "message_text": log.message_text, + "message_type": log.message_type, + "shown_at": log.shown_at.isoformat(), + "was_clicked": log.was_clicked + } + for log in logs + ] + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..af40d0a --- /dev/null +++ b/backend/app/api/tasks.py @@ -0,0 +1,218 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import date + +from app.core.database import get_db +from app.core.security import decode_access_token +from app.models.task import Task +from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate + +router = APIRouter() +security = HTTPBearer() + +def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: + """Extrai user_id do token JWT""" + token = credentials.credentials + payload = decode_access_token(token) + if not payload: + raise HTTPException(status_code=401, detail="Token inválido") + return payload.get("sub") + +@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED) +async def create_task( + task_data: TaskCreate, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Criar nova tarefa""" + new_task = Task( + user_id=user_id, + title=task_data.title, + description=task_data.description, + priority=task_data.priority, + status=task_data.status, + due_date=task_data.due_date, + due_time=task_data.due_time, + category_id=task_data.category_id + ) + + db.add(new_task) + db.commit() + db.refresh(new_task) + + return new_task + +@router.get("/", response_model=List[TaskResponse]) +async def list_tasks( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db), + status_filter: Optional[str] = None, + include_archived: bool = False +): + """Listar todas as tarefas do usuário""" + query = db.query(Task).filter(Task.user_id == user_id) + + if not include_archived: + query = query.filter(Task.is_archived == False) + + if status_filter: + query = query.filter(Task.status == status_filter) + + tasks = query.order_by(Task.due_date.asc()).all() + + return tasks + +@router.get("/today", response_model=List[TaskResponse]) +async def get_today_tasks( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Obter tarefas de hoje""" + today = date.today() + tasks = db.query(Task).filter( + Task.user_id == user_id, + Task.due_date == today, + Task.is_archived == False + ).order_by(Task.due_time.asc()).all() + + return tasks + +@router.get("/{task_id}", response_model=TaskResponse) +async def get_task( + task_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Obter tarefa específica""" + task = db.query(Task).filter( + Task.id == task_id, + Task.user_id == user_id + ).first() + + if not task: + raise HTTPException(status_code=404, detail="Tarefa não encontrada") + + return task + +@router.put("/{task_id}", response_model=TaskResponse) +async def update_task( + task_id: str, + task_data: TaskUpdate, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Atualizar tarefa""" + task = db.query(Task).filter( + Task.id == task_id, + Task.user_id == user_id + ).first() + + if not task: + raise HTTPException(status_code=404, detail="Tarefa não encontrada") + + # Atualizar campos + update_data = task_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(task, field, value) + + db.commit() + db.refresh(task) + + return task + +@router.patch("/{task_id}/status") +async def update_task_status( + task_id: str, + status: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Atualizar status da tarefa""" + task = db.query(Task).filter( + Task.id == task_id, + Task.user_id == user_id + ).first() + + if not task: + raise HTTPException(status_code=404, detail="Tarefa não encontrada") + + task.status = status + db.commit() + + return {"message": "Status atualizado", "status": status} + +@router.delete("/{task_id}") +async def delete_task( + task_id: str, + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Deletar tarefa""" + task = db.query(Task).filter( + Task.id == task_id, + Task.user_id == user_id + ).first() + + if not task: + raise HTTPException(status_code=404, detail="Tarefa não encontrada") + + db.delete(task) + db.commit() + + return {"message": "Tarefa deletada com sucesso"} + +@router.get("/stats/summary") +async def get_task_stats( + user_id: str = Depends(get_current_user_id), + db: Session = Depends(get_db) +): + """Estatísticas de tarefas""" + from sqlalchemy import func + + total_tasks = db.query(func.count(Task.id)).filter( + Task.user_id == user_id, + Task.is_archived == False + ).scalar() + + pending = db.query(func.count(Task.id)).filter( + Task.user_id == user_id, + Task.status == "pending", + Task.is_archived == False + ).scalar() + + in_progress = db.query(func.count(Task.id)).filter( + Task.user_id == user_id, + Task.status == "in_progress", + Task.is_archived == False + ).scalar() + + completed = db.query(func.count(Task.id)).filter( + Task.user_id == user_id, + Task.status == "completed", + Task.is_archived == False + ).scalar() + + today = date.today() + today_tasks = db.query(func.count(Task.id)).filter( + Task.user_id == user_id, + Task.due_date == today, + Task.is_archived == False + ).scalar() + + today_completed = db.query(func.count(Task.id)).filter( + Task.user_id == user_id, + Task.due_date == today, + Task.status == "completed" + ).scalar() + + return { + "total_tasks": total_tasks, + "pending": pending, + "in_progress": in_progress, + "completed": completed, + "today_tasks": today_tasks, + "today_completed": today_completed + } + diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..cd8c6c7 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,13 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str + REDIS_URL: str + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..107bd87 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://vida180_user:vida180_strong_password_2024@postgres:5432/vida180_db" +) + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..7bf9e51 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from app.core.config import settings + +# Usar Argon2 ao invés de Bcrypt +ph = PasswordHasher() + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verifica se a senha corresponde ao hash""" + try: + ph.verify(hashed_password, plain_password) + return True + except VerifyMismatchError: + return False + +def get_password_hash(password: str) -> str: + """Gera hash da senha""" + return ph.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Cria token JWT""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> Optional[dict]: + """Decodifica token JWT""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7ff50c3 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.database import engine, Base +from app.api import auth, habits, health, messages, admin, tasks + +app = FastAPI(title="Vida180 API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +Base.metadata.create_all(bind=engine) + +app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(habits.router, prefix="/api/v1/habits", tags=["habits"]) +app.include_router(health.router, prefix="/api/v1/health", tags=["health"]) +app.include_router(messages.router, prefix="/api/v1/messages", tags=["messages"]) +app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) +app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["tasks"]) + +@app.get("/") +def root(): + return {"message": "Vida180 API"} diff --git a/backend/app/migrations/add_superadmin.sql b/backend/app/migrations/add_superadmin.sql new file mode 100644 index 0000000..bcf7f3d --- /dev/null +++ b/backend/app/migrations/add_superadmin.sql @@ -0,0 +1,5 @@ +-- Adicionar campo is_superadmin +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE; + +-- Criar índice +CREATE INDEX IF NOT EXISTS idx_users_superadmin ON users(is_superadmin); diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..f3d9f4b --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# Models package diff --git a/backend/app/models/habit.py b/backend/app/models/habit.py new file mode 100644 index 0000000..0ec1f95 --- /dev/null +++ b/backend/app/models/habit.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, String, Integer, Boolean, Date, Time, DateTime, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +import uuid + +from app.core.database import Base + +class Habit(Base): + __tablename__ = "habits" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) + name = Column(String(255), nullable=False) + description = Column(Text) + frequency_type = Column(String(50), default='daily') + target_count = Column(Integer, default=1) + reminder_time = Column(Time) + start_date = Column(Date, default=func.current_date()) + end_date = Column(Date) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + +class HabitCompletion(Base): + __tablename__ = "habit_completions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + habit_id = Column(UUID(as_uuid=True), ForeignKey('habits.id', ondelete='CASCADE'), nullable=False, index=True) + user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) + completion_date = Column(Date, nullable=False, index=True) + completion_time = Column(Time) + notes = Column(Text) + quality_rating = Column(Integer) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/health.py b/backend/app/models/health.py new file mode 100644 index 0000000..0ea37d5 --- /dev/null +++ b/backend/app/models/health.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, String, Date, Time, Numeric, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +import uuid + +from app.core.database import Base + +class HealthMetric(Base): + __tablename__ = "health_metrics" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + measurement_date = Column(Date, nullable=False) + measurement_time = Column(Time) + weight = Column(Numeric(5, 2)) + height = Column(Numeric(5, 2)) + body_fat_percentage = Column(Numeric(4, 2)) + muscle_mass = Column(Numeric(5, 2)) + waist = Column(Numeric(5, 2)) + chest = Column(Numeric(5, 2)) + hips = Column(Numeric(5, 2)) + notes = Column(String) diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..dfeedec --- /dev/null +++ b/backend/app/models/message.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +import uuid + +from app.core.database import Base + +class MotivationalMessage(Base): + __tablename__ = "motivational_messages" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + message_type = Column(String(50), nullable=False) + trigger_condition = Column(String(100), nullable=False) + message_text = Column(Text, nullable=False) + icon = Column(String(10)) + priority = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class UserMessageLog(Base): + __tablename__ = "user_messages_log" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + message_id = Column(UUID(as_uuid=True), ForeignKey("motivational_messages.id", ondelete="SET NULL")) + message_text = Column(Text, nullable=False) + message_type = Column(String(50)) + shown_at = Column(DateTime(timezone=True), server_default=func.now()) + was_clicked = Column(Boolean, default=False) + clicked_at = Column(DateTime(timezone=True)) diff --git a/backend/app/models/streak.py b/backend/app/models/streak.py new file mode 100644 index 0000000..8acc23f --- /dev/null +++ b/backend/app/models/streak.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, Date, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +import uuid + +from app.core.database import Base + +class Streak(Base): + __tablename__ = "streaks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + habit_id = Column(UUID(as_uuid=True), ForeignKey("habits.id", ondelete="CASCADE"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + current_streak = Column(Integer, default=0) + longest_streak = Column(Integer, default=0) + last_completion_date = Column(Date) + total_completions = Column(Integer, default=0) diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..7268fdc --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, String, Date, Time, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +from datetime import datetime +import uuid + +from app.core.database import Base + +class Task(Base): + __tablename__ = "tasks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + category_id = Column(UUID(as_uuid=True), nullable=True) # Removido FK para categories por enquanto + title = Column(String(255), nullable=False) + description = Column(String) + priority = Column(String(20), default='medium') + status = Column(String(20), default='pending') + due_date = Column(Date) + due_time = Column(Time) + is_archived = Column(Boolean, default=False) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..98f1dbf --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +import uuid + +from app.core.database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False, index=True) + username = Column(String(100), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + full_name = Column(String(255)) + phone = Column(String(20)) # NOVO + avatar_url = Column(String) + timezone = Column(String(50), default='America/Sao_Paulo') + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + last_login_at = Column(DateTime(timezone=True)) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + is_superadmin = Column(Boolean, default=False) + verification_token = Column(String(255)) # NOVO - para confirmação de email diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..adc58ed --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime +import uuid + +class UserListResponse(BaseModel): + id: uuid.UUID + email: EmailStr + username: str + full_name: Optional[str] + is_active: bool + is_verified: bool + is_superadmin: bool + created_at: datetime + last_login_at: Optional[datetime] + + class Config: + from_attributes = True + +class UserUpdateRequest(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + full_name: Optional[str] = None + is_active: Optional[bool] = None + is_verified: Optional[bool] = None + +class PasswordChangeRequest(BaseModel): + new_password: str diff --git a/backend/app/schemas/habit.py b/backend/app/schemas/habit.py new file mode 100644 index 0000000..cbb78ba --- /dev/null +++ b/backend/app/schemas/habit.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import date, time +import uuid + +class HabitCreate(BaseModel): + name: str + description: Optional[str] = None + frequency_type: str = "daily" + target_count: int = 1 + reminder_time: Optional[time] = None + start_date: Optional[date] = None + +class HabitResponse(BaseModel): + id: uuid.UUID + name: str + description: Optional[str] + frequency_type: str + target_count: int + is_active: bool + start_date: date + + class Config: + from_attributes = True + +class HabitCompletionCreate(BaseModel): + notes: Optional[str] = None + quality_rating: Optional[int] = None diff --git a/backend/app/schemas/health.py b/backend/app/schemas/health.py new file mode 100644 index 0000000..ca24877 --- /dev/null +++ b/backend/app/schemas/health.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import date, time +from decimal import Decimal +import uuid + +class HealthMetricCreate(BaseModel): + measurement_date: date + measurement_time: Optional[time] = None + weight: Optional[Decimal] = None + height: Optional[Decimal] = None + body_fat_percentage: Optional[Decimal] = None + muscle_mass: Optional[Decimal] = None + waist: Optional[Decimal] = None + chest: Optional[Decimal] = None + hips: Optional[Decimal] = None + notes: Optional[str] = None + +class HealthMetricResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + measurement_date: date + measurement_time: Optional[time] + weight: Optional[Decimal] + height: Optional[Decimal] + body_fat_percentage: Optional[Decimal] + muscle_mass: Optional[Decimal] + waist: Optional[Decimal] + chest: Optional[Decimal] + hips: Optional[Decimal] + notes: Optional[str] + + class Config: + from_attributes = True + +class HealthMetricUpdate(BaseModel): + measurement_date: Optional[date] = None + measurement_time: Optional[time] = None + weight: Optional[Decimal] = None + height: Optional[Decimal] = None + body_fat_percentage: Optional[Decimal] = None + muscle_mass: Optional[Decimal] = None + waist: Optional[Decimal] = None + chest: Optional[Decimal] = None + hips: Optional[Decimal] = None + notes: Optional[str] = None + diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..d6d06df --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import date, time +import uuid + +class TaskCreate(BaseModel): + title: str + description: Optional[str] = None + priority: str = "medium" # low, medium, high + status: str = "pending" # pending, in_progress, completed + due_date: Optional[date] = None + due_time: Optional[time] = None + category_id: Optional[uuid.UUID] = None + +class TaskResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + title: str + description: Optional[str] + priority: str + status: str + due_date: Optional[date] + due_time: Optional[time] + is_archived: bool + category_id: Optional[uuid.UUID] + + class Config: + from_attributes = True + +class TaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + due_date: Optional[date] = None + due_time: Optional[time] = None + is_archived: Optional[bool] = None + category_id: Optional[uuid.UUID] = None + diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..983dde5 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,53 @@ +from pydantic import BaseModel, EmailStr, field_validator +from typing import Optional +import uuid +import re + +class UserBase(BaseModel): + email: EmailStr + username: str + full_name: str + phone: str + +class UserCreate(UserBase): + password: str + + @field_validator('phone') + @classmethod + def validate_phone(cls, v): + # Remove tudo que não é número + phone = re.sub(r'\D', '', v) + if len(phone) < 10 or len(phone) > 11: + raise ValueError('Telefone deve ter 10 ou 11 dígitos') + return phone + + @field_validator('full_name') + @classmethod + def validate_full_name(cls, v): + if not v or len(v.strip()) < 3: + raise ValueError('Nome completo deve ter no mínimo 3 caracteres') + return v.strip() + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class UserResponse(BaseModel): + id: uuid.UUID + email: EmailStr + username: str + full_name: Optional[str] + phone: Optional[str] + is_superadmin: bool = False + is_verified: bool = False + + class Config: + from_attributes = True + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + +class TokenData(BaseModel): + sub: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..1c49ad8 --- /dev/null +++ b/backend/app/services/email.py @@ -0,0 +1,246 @@ +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail, Email, To, Content +import logging +from app.core.config import settings + +logger = logging.getLogger(__name__) + +def send_verification_email(to_email: str, username: str, verification_token: str): + """Envia email de verificação para novo usuário""" + + verification_url = f"{settings.FRONTEND_URL}/verify-email/{verification_token}" + + # Template do email + html_content = f""" + + + + + + +
+
+

🚀 VIDA180

+
+ +
+

Olá, {username}! 👋

+ +

Seja muito bem-vindo(a) ao Vida180! 🎉

+ +

Estamos muito felizes em ter você conosco nessa jornada de transformação!

+ +

Para ativar sua conta e começar a usar todas as funcionalidades, clique no botão abaixo:

+ +
+ + ✅ Verificar Meu Email + +
+ +

+ Ou copie e cole este link no seu navegador:
+ {verification_url} +

+ +

Após verificar seu email, você terá acesso completo a:

+
    +
  • 🎯 Hábitos: Construa rotinas poderosas
  • +
  • 📝 Tarefas: Organize seu dia
  • +
  • 💪 Saúde: Acompanhe sua evolução física
  • +
  • 📊 Progresso: Visualize sua transformação
  • +
+ +

A transformação acontece um dia de cada vez! 💪

+
+ + +
+ + + """ + + try: + message = Mail( + from_email=Email(settings.SENDGRID_FROM_EMAIL, settings.SENDGRID_FROM_NAME), + to_emails=To(to_email), + subject=f"🚀 Bem-vindo ao Vida180! Confirme seu email", + html_content=Content("text/html", html_content) + ) + + sg = SendGridAPIClient(settings.SENDGRID_API_KEY) + response = sg.send(message) + + logger.info(f"Email enviado para {to_email} - Status: {response.status_code}") + return True + + except Exception as e: + logger.error(f"Erro ao enviar email para {to_email}: {str(e)}") + return False + +def send_password_reset_email(to_email: str, username: str, reset_token: str): + """Envia email de reset de senha""" + + reset_url = f"{settings.FRONTEND_URL}/reset-password/{reset_token}" + + html_content = f""" + + + + + + +
+
+

🚀 VIDA180

+
+ +

Olá, {username}! 👋

+ +

Recebemos uma solicitação para redefinir sua senha.

+ +
+ ⚠️ Se você não solicitou a alteração de senha, ignore este email e sua senha permanecerá a mesma. +
+ +

Para criar uma nova senha, clique no botão abaixo:

+ +
+ + 🔑 Redefinir Minha Senha + +
+ +

+ Este link expira em 24 horas. +

+ +
+ Vida180 - Transforme sua vida, um dia de cada vez 🚀 +
+
+ + + """ + + try: + message = Mail( + from_email=Email(settings.SENDGRID_FROM_EMAIL, settings.SENDGRID_FROM_NAME), + to_emails=To(to_email), + subject="🔑 Redefinir senha - Vida180", + html_content=Content("text/html", html_content) + ) + + sg = SendGridAPIClient(settings.SENDGRID_API_KEY) + response = sg.send(message) + + logger.info(f"Email de reset enviado para {to_email}") + return True + + except Exception as e: + logger.error(f"Erro ao enviar email de reset: {str(e)}") + return False diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py new file mode 100644 index 0000000..d201089 --- /dev/null +++ b/backend/app/services/message_service.py @@ -0,0 +1,290 @@ +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from sqlalchemy import func, and_ +from typing import Optional, Dict +import random + +from app.models.message import MotivationalMessage, UserMessageLog +from app.models.habit import Habit, HabitCompletion +from app.models.task import Task +from app.models.health import HealthMetric +from app.models.streak import Streak + + +class MessageService: + """Serviço para gerar mensagens contextuais inteligentes""" + + def __init__(self, db: Session): + self.db = db + + def get_message_of_the_day(self, user_id: str) -> Dict: + """Retorna a mensagem mais relevante para o usuário""" + + # 1. Verificar streak (prioridade alta) + streak_message = self._check_streak_achievements(user_id) + if streak_message: + self._log_message(user_id, streak_message) + return streak_message + + # 2. Verificar inatividade (recuperação) + inactive_message = self._check_inactivity(user_id) + if inactive_message: + self._log_message(user_id, inactive_message) + return inactive_message + + # 3. Verificar lembretes importantes + reminder_message = self._check_reminders(user_id) + if reminder_message: + self._log_message(user_id, reminder_message) + return reminder_message + + # 4. Verificar progresso e milestones + progress_message = self._check_progress(user_id) + if progress_message: + self._log_message(user_id, progress_message) + return progress_message + + # 5. Mensagem baseada em hora do dia + time_message = self._get_time_based_message() + if time_message: + self._log_message(user_id, time_message) + return time_message + + # 6. Mensagem motivacional padrão + default_message = self._get_daily_motivation() + self._log_message(user_id, default_message) + return default_message + + def _check_streak_achievements(self, user_id: str) -> Optional[Dict]: + """Verifica conquistas de streak""" + # Buscar maior streak do usuário + max_streak = self.db.query(func.max(Streak.current_streak)).filter( + Streak.user_id == user_id + ).scalar() or 0 + + # Definir condições de streak + conditions = [ + (100, 'streak_100'), + (60, 'streak_60'), + (30, 'streak_30'), + (14, 'streak_14'), + (7, 'streak_7') + ] + + for days, condition in conditions: + if max_streak >= days: + # Verificar se já mostrou essa mensagem hoje + already_shown = self.db.query(UserMessageLog).filter( + and_( + UserMessageLog.user_id == user_id, + UserMessageLog.message_type == 'streak_achievement', + func.date(UserMessageLog.shown_at) == datetime.now().date() + ) + ).first() + + if not already_shown: + message = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'streak_achievement', + MotivationalMessage.trigger_condition == condition, + MotivationalMessage.is_active == True + ) + ).first() + + if message: + return self._message_to_dict(message) + + return None + + def _check_inactivity(self, user_id: str) -> Optional[Dict]: + """Verifica se o usuário está inativo""" + last_log = self.db.query(UserMessageLog).filter( + UserMessageLog.user_id == user_id + ).order_by(UserMessageLog.shown_at.desc()).first() + + if not last_log: + return None + + days_inactive = (datetime.now() - last_log.shown_at).days + + if days_inactive >= 14: + condition = 'inactive_14days' + elif days_inactive >= 7: + condition = 'inactive_7days' + else: + return None + + message = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'recovery', + MotivationalMessage.trigger_condition == condition, + MotivationalMessage.is_active == True + ) + ).first() + + return self._message_to_dict(message) if message else None + + def _check_reminders(self, user_id: str) -> Optional[Dict]: + """Verifica lembretes importantes""" + # Verificar última medição de saúde + last_metric = self.db.query(HealthMetric).filter( + HealthMetric.user_id == user_id + ).order_by(HealthMetric.measurement_date.desc()).first() + + if last_metric: + days_since_last = (datetime.now().date() - last_metric.measurement_date).days + + if days_since_last >= 7: + condition = 'no_weight_7days' + elif days_since_last >= 3: + condition = 'no_weight_3days' + else: + condition = None + + if condition: + message = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'reminder', + MotivationalMessage.trigger_condition == condition, + MotivationalMessage.is_active == True + ) + ).first() + + if message: + return self._message_to_dict(message) + + # Verificar hábitos pendentes + pending_habits = self.db.query(Habit).filter( + and_( + Habit.user_id == user_id, + Habit.is_active == True, + ~Habit.id.in_( + self.db.query(HabitCompletion.habit_id).filter( + and_( + HabitCompletion.user_id == user_id, + HabitCompletion.completion_date == datetime.now().date() + ) + ) + ) + ) + ).count() + + if pending_habits > 0: + message = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'reminder', + MotivationalMessage.trigger_condition == 'habits_pending', + MotivationalMessage.is_active == True + ) + ).first() + + if message: + msg_dict = self._message_to_dict(message) + msg_dict['message_text'] = f"🎯 Você tem {pending_habits} hábitos esperando por você hoje!" + return msg_dict + + return None + + def _check_progress(self, user_id: str) -> Optional[Dict]: + """Verifica progresso e milestones""" + # Calcular consistência do mês + first_day_month = datetime.now().replace(day=1).date() + + total_habits_month = self.db.query(func.count(Habit.id)).filter( + and_( + Habit.user_id == user_id, + Habit.start_date <= datetime.now().date() + ) + ).scalar() + + completed_habits_month = self.db.query(func.count(HabitCompletion.id)).filter( + and_( + HabitCompletion.user_id == user_id, + HabitCompletion.completion_date >= first_day_month + ) + ).scalar() + + if total_habits_month > 0: + consistency = (completed_habits_month / total_habits_month) * 100 + + if consistency >= 80: + message = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'progress_milestone', + MotivationalMessage.trigger_condition == 'consistency_80', + MotivationalMessage.is_active == True + ) + ).first() + + if message: + msg_dict = self._message_to_dict(message) + msg_dict['message_text'] = f"💎 {int(consistency)}% de consistência esse mês! Top 10% dos usuários!" + return msg_dict + + return None + + def _get_time_based_message(self) -> Optional[Dict]: + """Retorna mensagem baseada na hora do dia""" + hour = datetime.now().hour + + if 5 <= hour < 12: + condition = 'morning' + elif 12 <= hour < 18: + condition = 'afternoon' + elif 18 <= hour < 22: + condition = 'evening' + else: + return None + + message = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'time_based', + MotivationalMessage.trigger_condition == condition, + MotivationalMessage.is_active == True + ) + ).first() + + return self._message_to_dict(message) if message else None + + def _get_daily_motivation(self) -> Dict: + """Retorna uma mensagem motivacional aleatória""" + messages = self.db.query(MotivationalMessage).filter( + and_( + MotivationalMessage.message_type == 'daily_motivation', + MotivationalMessage.is_active == True + ) + ).all() + + if messages: + message = random.choice(messages) + return self._message_to_dict(message) + + # Fallback + return { + 'id': None, + 'message_text': '💪 A transformação acontece um dia de cada vez. Esse dia é hoje!', + 'icon': '💪', + 'message_type': 'daily_motivation', + 'priority': 1 + } + + def _message_to_dict(self, message: MotivationalMessage) -> Dict: + """Converte mensagem para dicionário""" + return { + 'id': str(message.id) if message.id else None, + 'message_text': message.message_text, + 'icon': message.icon, + 'message_type': message.message_type, + 'priority': message.priority + } + + def _log_message(self, user_id: str, message: Dict): + """Registra mensagem exibida no histórico""" + log = UserMessageLog( + user_id=user_id, + message_id=message.get('id'), + message_text=message['message_text'], + message_type=message['message_type'] + ) + self.db.add(log) + self.db.commit() diff --git a/backend/init-db.sql b/backend/init-db.sql new file mode 100644 index 0000000..cec01ad --- /dev/null +++ b/backend/init-db.sql @@ -0,0 +1,247 @@ +-- Vida180 Database Schema +-- Created: 2024 + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255), + avatar_url TEXT, + timezone VARCHAR(50) DEFAULT 'America/Sao_Paulo', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP WITH TIME ZONE, + is_active BOOLEAN DEFAULT TRUE, + is_verified BOOLEAN DEFAULT FALSE +); + +-- Categories table +CREATE TABLE categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + color VARCHAR(7), + icon VARCHAR(50), + display_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name) +); + +-- Tasks table +CREATE TABLE tasks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id UUID REFERENCES categories(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + priority VARCHAR(20) DEFAULT 'medium', + status VARCHAR(20) DEFAULT 'pending', + due_date DATE, + due_time TIME, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_archived BOOLEAN DEFAULT FALSE +); + +-- Habits table +CREATE TABLE habits ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id UUID REFERENCES categories(id) ON DELETE SET NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + frequency_type VARCHAR(20) NOT NULL, + target_count INTEGER DEFAULT 1, + reminder_time TIME, + is_active BOOLEAN DEFAULT TRUE, + start_date DATE NOT NULL, + end_date DATE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Habit completions table +CREATE TABLE habit_completions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + habit_id UUID NOT NULL REFERENCES habits(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + completion_date DATE NOT NULL, + completion_time TIME, + notes TEXT, + quality_rating INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(habit_id, completion_date) +); + +-- Streaks table +CREATE TABLE streaks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + habit_id UUID NOT NULL REFERENCES habits(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + current_streak INTEGER DEFAULT 0, + longest_streak INTEGER DEFAULT 0, + last_completion_date DATE, + total_completions INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(habit_id) +); + +-- Health metrics table +CREATE TABLE health_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + measurement_date DATE NOT NULL, + measurement_time TIME DEFAULT CURRENT_TIME, + weight DECIMAL(5,2), + height DECIMAL(5,2), + body_fat_percentage DECIMAL(4,2), + muscle_mass DECIMAL(5,2), + waist DECIMAL(5,2), + chest DECIMAL(5,2), + hips DECIMAL(5,2), + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, measurement_date) +); + +-- Health goals table +CREATE TABLE health_goals ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + goal_type VARCHAR(50) NOT NULL, + metric_type VARCHAR(50) NOT NULL, + starting_value DECIMAL(5,2) NOT NULL, + target_value DECIMAL(5,2) NOT NULL, + current_value DECIMAL(5,2), + start_date DATE NOT NULL, + target_date DATE, + achieved_date DATE, + is_active BOOLEAN DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes +CREATE INDEX idx_categories_user_id ON categories(user_id); +CREATE INDEX idx_tasks_user_id ON tasks(user_id); +CREATE INDEX idx_tasks_status ON tasks(status); +CREATE INDEX idx_habits_user_id ON habits(user_id); +CREATE INDEX idx_health_metrics_user_id ON health_metrics(user_id); +CREATE INDEX idx_health_metrics_date ON health_metrics(measurement_date); + +-- ============================================ +-- MENSAGENS MOTIVACIONAIS +-- ============================================ + +CREATE TABLE motivational_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_type VARCHAR(50) NOT NULL, + trigger_condition VARCHAR(100) NOT NULL, + message_text TEXT NOT NULL, + icon VARCHAR(10), + priority INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT valid_message_type CHECK (message_type IN ( + 'streak_achievement', + 'reminder', + 'progress_milestone', + 'time_based', + 'special_milestone', + 'recovery', + 'daily_motivation' + )) +); + +-- Índices +CREATE INDEX idx_messages_type ON motivational_messages(message_type); +CREATE INDEX idx_messages_active ON motivational_messages(is_active); + +-- ============================================ +-- USER MESSAGES LOG (histórico de mensagens vistas) +-- ============================================ + +CREATE TABLE user_messages_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message_id UUID REFERENCES motivational_messages(id) ON DELETE SET NULL, + message_text TEXT NOT NULL, + message_type VARCHAR(50), + shown_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + was_clicked BOOLEAN DEFAULT FALSE, + clicked_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_user_messages_user_id ON user_messages_log(user_id); +CREATE INDEX idx_user_messages_shown_at ON user_messages_log(shown_at); + +-- ============================================ +-- INSERIR MENSAGENS PADRÃO +-- ============================================ + +-- Mensagens de Streak +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('streak_achievement', 'streak_7', '🔥 7 dias de sequência! Essa disciplina vai te levar longe!', '🔥', 10), +('streak_achievement', 'streak_14', '💪 14 dias! Você está construindo algo sólido aqui!', '💪', 15), +('streak_achievement', 'streak_30', '🏆 30 DIAS! A maioria desiste antes. Você não!', '🏆', 20), +('streak_achievement', 'streak_60', '⚡ 60 dias de transformação real! Continue nessa pegada!', '⚡', 25), +('streak_achievement', 'streak_100', '👑 100 DIAS! Você oficialmente virou o jogo da sua vida!', '👑', 30), +('streak_achievement', 'streak_365', '🎉 1 ANO! Você é lenda! Prova viva de transformação!', '🎉', 50); + +-- Lembretes +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('reminder', 'no_weight_3days', '📊 Faz 3 dias sem registrar peso. Que tal medir hoje?', '📊', 5), +('reminder', 'no_weight_7days', '⚠️ 7 dias sem medição. Continue acompanhando sua evolução!', '⚠️', 8), +('reminder', 'habits_pending', '🎯 Você tem hábitos esperando por você hoje!', '🎯', 7), +('reminder', 'tasks_pending', '✅ Tarefas do dia te aguardam. Vamos começar?', '✅', 6); + +-- Progresso +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('progress_milestone', 'consistency_80', '💎 80% de consistência esse mês! Top 10% dos usuários!', '💎', 12), +('progress_milestone', 'weight_loss_5kg', '🎊 5kg perdidos! A estrada está aparecendo, continue!', '🎊', 15), +('progress_milestone', 'weight_loss_10kg', '🌟 10kg! Transformação visível! Que orgulho!', '🌟', 20), +('progress_milestone', 'first_goal_achieved', '🏅 Primeiro objetivo alcançado! Defina o próximo!', '🏅', 18); + +-- Mensagens por Hora do Dia +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('time_based', 'morning', '🌅 Bom dia! Que tal começar o dia marcando suas metas?', '🌅', 3), +('time_based', 'afternoon', '☀️ Boa tarde! Como está o progresso de hoje?', '☀️', 2), +('time_based', 'evening', '🌙 Boa noite! Antes de dormir, registre o que conquistou hoje.', '🌙', 3), +('time_based', 'monday', '💼 Segunda-feira! Nova semana, novas vitórias! Vamos lá!', '💼', 4); + +-- Milestones Especiais +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('special_milestone', 'first_habit_complete', '⭐ Primeiro hábito completado! Esse é o início!', '⭐', 8), +('special_milestone', 'all_tasks_complete', '✨ TODAS as tarefas do dia completas! Incrível!', '✨', 10), +('special_milestone', 'perfect_week', '🔥 Semana perfeita! 7 dias sem falhas! Imparável!', '🔥', 15); + +-- Recuperação +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('recovery', 'inactive_7days', '👋 Sentimos sua falta! A vida acontece, mas você pode retomar AGORA!', '👋', 9), +('recovery', 'inactive_14days', '💙 14 dias offline. Que tal voltar hoje? Estamos aqui pra você!', '💙', 11), +('recovery', 'streak_broken', '🔄 Quebrou a sequência? Normal! Recomeçar é parte da jornada.', '🔄', 7); + +-- Motivacionais Diárias (genéricas) +INSERT INTO motivational_messages (message_type, trigger_condition, message_text, icon, priority) VALUES +('daily_motivation', 'default_1', '💪 A transformação acontece um dia de cada vez. Esse dia é hoje!', '💪', 1), +('daily_motivation', 'default_2', '🚀 Cada pequena vitória hoje constrói o grande resultado de amanhã.', '🚀', 1), +('daily_motivation', 'default_3', '⚡ Você não precisa ser perfeito. Só precisa ser consistente.', '⚡', 1), +('daily_motivation', 'default_4', '🎯 O segredo do sucesso? Aparecer todos os dias.', '🎯', 1), +('daily_motivation', 'default_5', '🌟 Seu eu do futuro vai agradecer pelo que você faz hoje.', '🌟', 1), +('daily_motivation', 'default_6', '💎 Pequenos passos, grandes mudanças. Continue!', '💎', 1), +('daily_motivation', 'default_7', '🔥 A disciplina de hoje é a liberdade de amanhã.', '🔥', 1); + +COMMENT ON TABLE motivational_messages IS 'Banco de mensagens motivacionais contextuais'; +COMMENT ON TABLE user_messages_log IS 'Histórico de mensagens exibidas para cada usuário'; diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..197dc31 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +redis==5.0.1 +python-dotenv==1.0.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# Authentication +python-jose[cryptography]==3.3.0 +argon2-cffi==23.1.0 +python-multipart==0.0.6 + +# Email +sendgrid==6.11.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f13b35d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: vida180_postgres + environment: + POSTGRES_DB: vida180_db + POSTGRES_USER: vida180_user + POSTGRES_PASSWORD: vida180_password + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5433:5432" + networks: + - vida180_network + + redis: + image: redis:7-alpine + container_name: vida180_redis + ports: + - "6380:6379" + networks: + - vida180_network + + backend: + build: ./backend + container_name: vida180_backend + environment: + DATABASE_URL: postgresql://vida180_user:vida180_password@postgres:5432/vida180_db + REDIS_URL: redis://redis:6379 + SECRET_KEY: your-secret-key-change-this-in-production + ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: 10080 + ports: + - "8000:8000" + depends_on: + - postgres + - redis + networks: + - vida180_network + + frontend: + build: ./frontend + container_name: vida180_frontend + ports: + - "3200:3000" + depends_on: + - backend + networks: + - vida180_network + +volumes: + postgres_data: + +networks: + vida180_network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..034396a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy application +COPY . . + +# Expose port +EXPOSE 3000 + +# Start development server +CMD ["npm", "start"] diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..02aa322 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "vida180-frontend", + "version": "1.0.0", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "axios": "^1.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "react-scripts": "5.0.1", + "typescript": "^4.9.5", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "tailwindcss": "^3.3.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31" + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..b184e5a --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + Vida180 - Transforme sua vida + + + +
+ + diff --git a/frontend/public/vida180_background.jpg b/frontend/public/vida180_background.jpg new file mode 100644 index 0000000..11777ce Binary files /dev/null and b/frontend/public/vida180_background.jpg differ diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..001eabb --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,43 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', + 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', + 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.loading-screen { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.loading-spinner { + font-size: 4rem; + animation: bounce 1s ease-in-out infinite; +} + +.loading-screen p { + margin-top: 1rem; + font-size: 1.2rem; + font-weight: 600; +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-20px); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..73f798e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Dashboard from './pages/Dashboard'; +import Habits from './pages/Habits'; +import Admin from './pages/Admin'; +import Tasks from './pages/Tasks'; +import Health from './pages/Health'; +import Progress from './pages/Progress'; +import './App.css'; + +type Page = 'dashboard' | 'habits' | 'admin' | 'tasks' | 'health' | 'progress'; + +const AppContent: React.FC = () => { + const { user, loading } = useAuth(); + const [showRegister, setShowRegister] = useState(false); + const [currentPage, setCurrentPage] = useState('dashboard'); + + if (loading) { + return ( +
+
🚀
+

Carregando Vida180...

+
+ ); + } + + if (user) { + switch (currentPage) { + case 'habits': + return setCurrentPage('dashboard')} />; + case 'admin': + return setCurrentPage('dashboard')} />; + case 'tasks': + return setCurrentPage('dashboard')} />; + case 'health': + return setCurrentPage('dashboard')} />; + case 'progress': + return setCurrentPage('dashboard')} />; + case 'dashboard': + default: + return ; + } + } + + return showRegister ? ( + setShowRegister(false)} /> + ) : ( + setShowRegister(true)} /> + ); +}; + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/frontend/src/background-internal.jpg b/frontend/src/background-internal.jpg new file mode 100644 index 0000000..cae90bf Binary files /dev/null and b/frontend/src/background-internal.jpg differ diff --git a/frontend/src/components/DailyMessage.css b/frontend/src/components/DailyMessage.css new file mode 100644 index 0000000..00a6362 --- /dev/null +++ b/frontend/src/components/DailyMessage.css @@ -0,0 +1,132 @@ +.daily-message { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); + backdrop-filter: blur(10px); + border: 2px solid rgba(102, 126, 234, 0.3); + border-radius: 16px; + padding: 1.5rem; + margin: 1.5rem 0; + display: flex; + align-items: center; + gap: 1rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.daily-message::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.daily-message:hover::before { + left: 100%; +} + +.daily-message:hover { + transform: translateY(-3px); + box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3); + border-color: rgba(102, 126, 234, 0.6); +} + +.daily-message .icon { + font-size: 2.5rem; + flex-shrink: 0; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +.daily-message .message-text { + flex: 1; + color: #1a202c; + font-size: 1rem; + font-weight: 600; + line-height: 1.5; + text-align: left; + margin: 0; +} + +.daily-message .message-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(102, 126, 234, 0.2); + color: #5b21b6; + font-size: 0.7rem; + font-weight: 700; + padding: 0.3rem 0.6rem; + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Tipos específicos de mensagem */ +.daily-message.streak_achievement { + border-color: rgba(234, 179, 8, 0.5); + background: linear-gradient(135deg, rgba(234, 179, 8, 0.1) 0%, rgba(245, 158, 11, 0.1) 100%); +} + +.daily-message.reminder { + border-color: rgba(59, 130, 246, 0.5); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.1) 100%); +} + +.daily-message.progress_milestone { + border-color: rgba(16, 185, 129, 0.5); + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%); +} + +.daily-message.recovery { + border-color: rgba(139, 92, 246, 0.5); + background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(124, 58, 237, 0.1) 100%); +} + +/* Loading state */ +.daily-message.loading { + justify-content: center; + border-style: dashed; +} + +.loading-spinner { + font-size: 2rem; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Error state */ +.daily-message.error { + border-color: rgba(239, 68, 68, 0.3); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.05) 0%, rgba(220, 38, 38, 0.05) 100%); +} + +/* Mobile */ +@media (max-width: 768px) { + .daily-message { + padding: 1rem; + flex-direction: column; + text-align: center; + } + + .daily-message .icon { + font-size: 2rem; + } + + .daily-message .message-text { + text-align: center; + font-size: 0.9rem; + } +} diff --git a/frontend/src/components/DailyMessage.tsx b/frontend/src/components/DailyMessage.tsx new file mode 100644 index 0000000..dad7b34 --- /dev/null +++ b/frontend/src/components/DailyMessage.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import './DailyMessage.css'; + +interface Message { + id: string | null; + message_text: string; + icon: string; + message_type: string; + priority: number; +} + +interface DailyMessageProps { + userId: string; +} + +const DailyMessage: React.FC = ({ userId }) => { + const [message, setMessage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + fetchDailyMessage(); + }, [userId]); + + const fetchDailyMessage = async () => { + try { + const backendUrl = `${window.location.protocol}//${window.location.host}`; + + const response = await fetch( + `${backendUrl}/api/v1/messages/daily?user_id=${userId}` + ); + + if (!response.ok) throw new Error('Erro ao buscar mensagem'); + + const data = await response.json(); + setMessage(data.message); + setLoading(false); + } catch (err) { + console.error('Erro ao carregar mensagem:', err); + setError(true); + setLoading(false); + } + }; + + const handleClick = async () => { + if (message?.id) { + try { + const backendUrl = `${window.location.protocol}//${window.location.host}`; + + await fetch( + `${backendUrl}/api/v1/messages/click/${message.id}?user_id=${userId}`, + { method: 'POST' } + ); + } catch (err) { + console.error('Erro ao registrar click:', err); + } + } + }; + + if (loading) { + return ( +
+
+

Carregando mensagem...

+
+ ); + } + + if (error || !message) { + return ( +
+ 💪 +

A transformação acontece um dia de cada vez!

+
+ ); + } + + return ( +
+ {message.icon} +

{message.message_text}

+
{getMessageTypeBadge(message.message_type)}
+
+ ); +}; + +const getMessageTypeBadge = (type: string): string => { + const badges: { [key: string]: string } = { + 'streak_achievement': '🏆 Conquista', + 'reminder': '🔔 Lembrete', + 'progress_milestone': '📈 Progresso', + 'time_based': '⏰ Momento', + 'special_milestone': '⭐ Especial', + 'recovery': '💙 Retorno', + 'daily_motivation': '💪 Motivação' + }; + return badges[type] || '✨ Mensagem'; +}; + +export default DailyMessage; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..83f0bef --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,115 @@ +import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react'; + +interface User { + id: string; + username: string; + email: string; + full_name?: string; + phone?: string; + is_superadmin?: boolean; + is_verified?: boolean; +} + +interface AuthContextType { + user: User | null; + token: string | null; + login: (email: string, password: string) => Promise; + register: (email: string, username: string, password: string, fullName: string, phone: string) => Promise; + logout: () => void; + loading: boolean; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + + // Usar a URL correta do backend + const backendUrl = window.location.hostname === 'localhost' + ? 'http://localhost:8000' + : `${window.location.protocol}//${window.location.hostname}`; + + useEffect(() => { + const storedToken = localStorage.getItem('token'); + const storedUser = localStorage.getItem('user'); + + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + } + setLoading(false); + }, []); + + const login = async (email: string, password: string) => { + const response = await fetch(`${backendUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Login failed'); + } + + const data = await response.json(); + setToken(data.access_token); + setUser(data.user); + localStorage.setItem('token', data.access_token); + localStorage.setItem('user', JSON.stringify(data.user)); + }; + + const register = async ( + email: string, + username: string, + password: string, + fullName: string, + phone: string + ) => { + const response = await fetch(`${backendUrl}/api/v1/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + username, + password, + full_name: fullName, + phone + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Registration failed'); + } + + const data = await response.json(); + setToken(data.access_token); + setUser(data.user); + localStorage.setItem('token', data.access_token); + localStorage.setItem('user', JSON.stringify(data.user)); + }; + + const logout = () => { + setUser(null); + setToken(null); + localStorage.removeItem('token'); + localStorage.removeItem('user'); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..2e9bb4a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,25 @@ +@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800;900&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', + 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +#root { + min-height: 100vh; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..524131e --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); + +root.render( + + + +); diff --git a/frontend/src/pages/Admin.css b/frontend/src/pages/Admin.css new file mode 100644 index 0000000..fc7d433 --- /dev/null +++ b/frontend/src/pages/Admin.css @@ -0,0 +1,283 @@ +.admin-page { + min-height: 100vh; + background: url('../background-internal.jpg') center/cover no-repeat fixed; + position: relative; + padding: 2rem; +} + +.admin-page::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.4); + z-index: 0; +} + +.admin-header { + position: relative; + z-index: 1; + max-width: 1400px; + margin: 0 auto 2rem; + display: flex; + align-items: center; + gap: 1rem; + background: rgba(255, 255, 255, 0.95); + padding: 1.5rem; + border-radius: 16px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); +} + +.admin-header h1 { + font-size: 2rem; + color: #8b5cf6; + margin: 0; +} + +.admin-stats { + position: relative; + z-index: 1; + max-width: 1400px; + margin: 0 auto 2rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.stat-box { + background: rgba(255, 255, 255, 0.92); + padding: 1.5rem; + border-radius: 12px; + display: flex; + align-items: center; + gap: 1rem; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); +} + +.stat-icon { + font-size: 2.5rem; +} + +.stat-info { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 2rem; + font-weight: 800; + color: #8b5cf6; +} + +.stat-label { + font-size: 0.85rem; + color: #64748b; +} + +.users-section { + position: relative; + z-index: 1; + max-width: 1400px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.92); + padding: 2rem; + border-radius: 16px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); +} + +.users-section h2 { + margin: 0 0 1.5rem 0; + color: #1a202c; +} + +.users-table { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +thead { + background: #f8fafc; +} + +th { + padding: 1rem; + text-align: left; + font-weight: 700; + color: #475569; + border-bottom: 2px solid #e2e8f0; +} + +td { + padding: 1rem; + border-bottom: 1px solid #e2e8f0; +} + +tr.inactive { + opacity: 0.5; +} + +.badge-admin { + display: inline-block; + margin-left: 0.5rem; + padding: 0.25rem 0.5rem; + background: #8b5cf6; + color: white; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 700; +} + +.status-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 600; +} + +.status-badge.active { + background: #d1fae5; + color: #065f46; +} + +.status-badge.inactive { + background: #fee; + color: #991b1b; +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.actions button { + padding: 0.5rem; + border: none; + border-radius: 6px; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-toggle { + background: #f3f4f6; +} + +.btn-toggle:hover { + transform: scale(1.1); +} + +.btn-password { + background: #dbeafe; +} + +.btn-password:hover { + background: #3b82f6; +} + +.btn-delete { + background: #fee; +} + +.btn-delete:hover { + background: #ef4444; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: white; + padding: 2rem; + border-radius: 20px; + max-width: 500px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.modal-content h2 { + margin: 0 0 1rem 0; + color: #8b5cf6; +} + +.modal-content p { + margin-bottom: 1.5rem; + color: #64748b; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-weight: 600; + color: #2d3748; + margin-bottom: 0.5rem; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + font-family: 'Poppins', sans-serif; +} + +.modal-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +.btn-cancel { + padding: 0.75rem 1.5rem; + background: #e5e7eb; + color: #374151; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.btn-confirm { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + border: none; + border-radius: 8px; + font-weight: 700; + cursor: pointer; +} + +.loading { + text-align: center; + font-size: 1.5rem; + padding: 3rem; +} + +.btn-edit { + background: #fef3c7; +} + +.btn-edit:hover { + background: #fbbf24; +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..2159c1e --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,382 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import './Admin.css'; + +interface User { + id: string; + email: string; + username: string; + full_name?: string; + is_active: boolean; + is_verified: boolean; + is_superadmin: boolean; + created_at: string; + last_login_at?: string; +} + +interface Stats { + total_users: number; + active_users: number; + inactive_users: number; + total_habits: number; + total_completions: number; +} + +const Admin: React.FC<{ onBack: () => void }> = ({ onBack }) => { + const { token } = useAuth(); + const [users, setUsers] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedUser, setSelectedUser] = useState(null); + const [modalType, setModalType] = useState<'password' | 'edit' | null>(null); + const [newPassword, setNewPassword] = useState(''); + const [editData, setEditData] = useState({ + email: '', + username: '', + full_name: '' + }); + + const backendUrl = `${window.location.protocol}//${window.location.host}`; + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + const statsRes = await fetch(`${backendUrl}/api/v1/admin/stats`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (statsRes.ok) { + setStats(await statsRes.json()); + } + + const usersRes = await fetch(`${backendUrl}/api/v1/admin/users`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (usersRes.ok) { + setUsers(await usersRes.json()); + } + } catch (error) { + console.error('Erro ao carregar dados:', error); + } finally { + setLoading(false); + } + }; + + const openEditModal = (user: User) => { + setSelectedUser(user); + setEditData({ + email: user.email, + username: user.username, + full_name: user.full_name || '' + }); + setModalType('edit'); + }; + + const openPasswordModal = (user: User) => { + setSelectedUser(user); + setNewPassword(''); + setModalType('password'); + }; + + const updateUser = async () => { + if (!selectedUser) return; + + try { + const response = await fetch(`${backendUrl}/api/v1/admin/users/${selectedUser.id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(editData) + }); + + if (response.ok) { + alert('✅ Usuário atualizado!'); + setModalType(null); + setSelectedUser(null); + loadData(); + } else { + const error = await response.json(); + alert('❌ Erro: ' + (error.detail || 'Erro ao atualizar')); + } + } catch (error) { + console.error('Erro ao atualizar usuário:', error); + alert('❌ Erro ao atualizar usuário'); + } + }; + + const toggleUserActive = async (userId: string, currentStatus: boolean) => { + try { + const response = await fetch(`${backendUrl}/api/v1/admin/users/${userId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ is_active: !currentStatus }) + }); + + if (response.ok) { + loadData(); + } + } catch (error) { + console.error('Erro ao atualizar usuário:', error); + } + }; + + const deleteUser = async (userId: string) => { + if (!confirm('Tem certeza que deseja desativar este usuário?')) return; + + try { + const response = await fetch(`${backendUrl}/api/v1/admin/users/${userId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + loadData(); + } + } catch (error) { + console.error('Erro ao deletar usuário:', error); + } + }; + + const resetPassword = async () => { + if (!selectedUser) return; + + if (!newPassword || newPassword.length < 6) { + alert('Senha deve ter no mínimo 6 caracteres!'); + return; + } + + try { + const response = await fetch(`${backendUrl}/api/v1/admin/users/${selectedUser.id}/reset-password`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ new_password: newPassword }) + }); + + if (response.ok) { + alert('✅ Senha alterada com sucesso!'); + setModalType(null); + setSelectedUser(null); + setNewPassword(''); + } + } catch (error) { + console.error('Erro ao resetar senha:', error); + } + }; + + if (loading) { + return
⏳ Carregando...
; + } + + return ( +
+
+ +

👨‍💼 Painel Administrativo

+
+ + {stats && ( +
+
+
👥
+
+ {stats.total_users} + Total Usuários +
+
+ +
+
+
+ {stats.active_users} + Ativos +
+
+ +
+
+
+ {stats.inactive_users} + Inativos +
+
+ +
+
🎯
+
+ {stats.total_habits} + Hábitos +
+
+ +
+
📊
+
+ {stats.total_completions} + Completions +
+
+
+ )} + +
+

👥 Usuários Cadastrados

+
+ + + + + + + + + + + + + {users.map(user => ( + + + + + + + + + ))} + +
EmailUsernameNomeStatusCadastroAções
{user.email} + {user.username} + {user.is_superadmin && ADMIN} + {user.full_name || '-'} + + {user.is_active ? 'Ativo' : 'Inativo'} + + {new Date(user.created_at).toLocaleDateString('pt-BR')} + {!user.is_superadmin && ( + <> + + + + + + + + + )} +
+
+
+ + {/* Modal de Editar Usuário */} + {modalType === 'edit' && selectedUser && ( +
setModalType(null)}> +
e.stopPropagation()}> +

✏️ Editar Usuário

+ +
+ + setEditData({...editData, email: e.target.value})} + /> +
+ +
+ + setEditData({...editData, username: e.target.value})} + /> +
+ +
+ + setEditData({...editData, full_name: e.target.value})} + /> +
+ +
+ + +
+
+
+ )} + + {/* Modal de Reset de Senha */} + {modalType === 'password' && selectedUser && ( +
setModalType(null)}> +
e.stopPropagation()}> +

🔑 Resetar Senha

+

Usuário: {selectedUser.username}

+ +
+ + setNewPassword(e.target.value)} + placeholder="Mínimo 6 caracteres" + autoFocus + /> +
+ +
+ + +
+
+
+ )} +
+ ); +}; + +export default Admin; diff --git a/frontend/src/pages/Auth.css b/frontend/src/pages/Auth.css new file mode 100644 index 0000000..4b9197c --- /dev/null +++ b/frontend/src/pages/Auth.css @@ -0,0 +1,190 @@ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: url('../vida180_background.jpg') center/cover no-repeat fixed; + padding: 1rem; + position: relative; +} + +.auth-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.3); /* REDUZI DE 0.85 PARA 0.3 */ +} + +.auth-box { + background: rgba(255, 255, 255, 0.95); /* MAIS OPACO PARA LEITURA */ + backdrop-filter: blur(12px); + border-radius: 20px; + padding: 2.5rem; + max-width: 450px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + animation: fadeInUp 0.5s ease-out; + position: relative; + z-index: 1; + border: 3px solid #10b981; +} + +.auth-header { + text-align: center; + margin-bottom: 2rem; +} + +.auth-header h1 { + font-size: 2.5rem; + font-weight: 800; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; + margin-bottom: 0.5rem; +} + +.auth-header p { + color: #4a5568; + font-weight: 600; + margin: 0; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: 600; + color: #2d3748; + font-size: 0.9rem; +} + +.form-group input { + padding: 0.875rem 1rem; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 1rem; + transition: all 0.3s ease; + font-family: 'Poppins', sans-serif; +} + +.form-group input:focus { + outline: none; + border-color: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); +} + +.form-group input:disabled { + background: #f7fafc; + cursor: not-allowed; +} + +.error-message { + background: linear-gradient(135deg, #fee 0%, #fdd 100%); + border: 2px solid #ef4444; + border-radius: 12px; + padding: 0.875rem; + color: #dc2626; + font-weight: 600; + font-size: 0.9rem; + text-align: center; +} + +.auth-button { + padding: 1rem; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + border-radius: 12px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 0.5rem; + font-family: 'Poppins', sans-serif; +} + +.auth-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 25px rgba(16, 185, 129, 0.4); +} + +.auth-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-footer { + text-align: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 2px solid #e2e8f0; +} + +.auth-footer p { + color: #64748b; + font-size: 0.9rem; + margin: 0; +} + +.link-button { + background: none; + border: none; + color: #10b981; + font-weight: 700; + cursor: pointer; + text-decoration: underline; + font-size: 0.9rem; + font-family: 'Poppins', sans-serif; + padding: 0; +} + +.link-button:hover { + color: #059669; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .auth-box { + padding: 2rem 1.5rem; + } + + .auth-header h1 { + font-size: 2rem; + } +} + +.verification-notice { + margin-top: 1rem; + padding: 1rem; + background: #dbeafe; + border: 2px solid #3b82f6; + border-radius: 12px; + text-align: center; + font-size: 0.9rem; + color: #1e40af; + font-weight: 600; +} diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100644 index 0000000..a8c5642 --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -0,0 +1,238 @@ +.dashboard { + min-height: 100vh; + background: url('../background-internal.jpg') center/cover no-repeat fixed; + position: relative; +} + +.dashboard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.4); /* REDUZI DE 0.85 PARA 0.4 */ + z-index: 0; +} + +.dashboard-header { + background: rgba(255, 255, 255, 0.95); /* MAIS OPACO */ + backdrop-filter: blur(10px); + border-bottom: 3px solid #10b981; + padding: 1.5rem 0; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + position: relative; + z-index: 10; +} + +.header-content { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.dashboard-header h1 { + font-size: 2rem; + font-weight: 800; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; +} + +.user-menu { + display: flex; + align-items: center; + gap: 1rem; +} + +.user-name { + font-weight: 600; + color: #2d3748; +} + +.logout-button { + padding: 0.5rem 1.5rem; + background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.logout-button:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(249, 115, 22, 0.4); +} + +.dashboard-main { + padding: 2rem 0; + position: relative; + z-index: 1; +} + +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.welcome-section { + background: rgba(255, 255, 255, 0.92); /* MAIS OPACO */ + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 2rem; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); + border: 2px solid #10b981; +} + +.welcome-section h2 { + margin: 0 0 1rem 0; + color: #059669; + font-size: 1.75rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.stat-card { + background: rgba(255, 255, 255, 0.92); /* MAIS OPACO */ + backdrop-filter: blur(10px); + border-radius: 16px; + padding: 1.5rem; + display: flex; + gap: 1rem; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.12); + transition: all 0.3s ease; + border: 2px solid transparent; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3); + border-color: #10b981; +} + +.stat-icon { + font-size: 2.5rem; + flex-shrink: 0; +} + +.stat-content { + flex: 1; +} + +.stat-content h3 { + margin: 0; + font-size: 0.9rem; + color: #64748b; + font-weight: 600; +} + +.stat-value { + font-size: 2rem; + font-weight: 800; + color: #059669; + margin: 0.25rem 0; +} + +.stat-label { + font-size: 0.85rem; + color: #94a3b8; +} + +.main-areas { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.area-card { + background: rgba(255, 255, 255, 0.92); /* MAIS OPACO */ + backdrop-filter: blur(10px); + border-radius: 16px; + padding: 2rem; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.12); + transition: all 0.3s ease; + border: 2px solid transparent; +} + +.area-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3); + border-color: #10b981; +} + +.area-card h3 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + color: #059669; +} + +.area-card p { + color: #64748b; + margin: 0 0 1.5rem 0; + line-height: 1.6; +} + +.area-button { + width: 100%; + padding: 0.875rem; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + border-radius: 10px; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; +} + +.area-button:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4); +} + +@media (max-width: 768px) { + .header-content { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .stats-grid, + .main-areas { + grid-template-columns: 1fr; + } + + .dashboard-container { + padding: 0 1rem; + } +} + +.admin-button { + padding: 0.5rem 1.5rem; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.admin-button:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(139, 92, 246, 0.4); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..f03f7b3 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,213 @@ +import React, { useEffect, useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import DailyMessage from '../components/DailyMessage'; +import './Dashboard.css'; + +type Page = 'dashboard' | 'habits' | 'admin' | 'tasks' | 'health' | 'progress'; + +interface DashboardProps { + onNavigate: (page: Page) => void; +} + +const Dashboard: React.FC = ({ onNavigate }) => { + const { user, logout, token } = useAuth(); + const [stats, setStats] = useState({ + totalHabits: 0, + completedToday: 0, + pendingToday: 0 + }); + const [taskStats, setTaskStats] = useState({ + today_tasks: 0, + today_completed: 0 + }); + const [healthStats, setHealthStats] = useState({ + current_weight: null as number | null + }); + + const backendUrl = `${window.location.protocol}//${window.location.host}`; + + useEffect(() => { + loadStats(); + loadTaskStats(); + loadHealthStats(); + }, []); + + const loadStats = async () => { + try { + const response = await fetch(`${backendUrl}/api/v1/habits/stats`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setStats({ + totalHabits: data.total_habits, + completedToday: data.completed_today, + pendingToday: data.pending_today + }); + } + } catch (error) { + console.error('Erro ao carregar stats:', error); + } + }; + + const loadTaskStats = async () => { + try { + const response = await fetch(`${backendUrl}/api/v1/tasks/stats/summary`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setTaskStats({ + today_tasks: data.today_tasks, + today_completed: data.today_completed + }); + } + } catch (error) { + console.error('Erro ao carregar stats de tarefas:', error); + } + }; + + const loadHealthStats = async () => { + try { + const response = await fetch(`${backendUrl}/api/v1/health/stats/summary`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setHealthStats({ + current_weight: data.current_weight + }); + } + } catch (error) { + console.error('Erro ao carregar stats de saúde:', error); + } + }; + + return ( +
+
+
+

🚀 VIDA180

+
+ Olá, {user?.username}! + + {/* BOTÃO ADMIN - SÓ APARECE PARA SUPERADMIN */} + {user?.is_superadmin && ( + + )} + + +
+
+
+ +
+
+
+

Bem-vindo de volta! 💪

+ +
+ +
+
+
🔥
+
+

Sequência

+

7 dias

+ Continue assim! +
+
+ +
+
+
+

Tarefas Hoje

+

{taskStats.today_completed}/{taskStats.today_tasks}

+ + {taskStats.today_tasks - taskStats.today_completed > 0 + ? `Faltam ${taskStats.today_tasks - taskStats.today_completed}` + : 'Tudo completo! 🎉'} + +
+
+ +
+
🎯
+
+

Hábitos

+

{stats.completedToday}/{stats.totalHabits}

+ + {stats.pendingToday > 0 + ? `Faltam ${stats.pendingToday}` + : 'Todos completos! 🎉'} + +
+
+ +
+
📊
+
+

Peso Atual

+

+ {healthStats.current_weight ? `${healthStats.current_weight} kg` : '-- kg'} +

+ + {healthStats.current_weight ? 'Continue assim!' : 'Registre hoje'} + +
+
+
+ +
+
+

📝 Tarefas

+

Organize seu dia e conquiste seus objetivos

+ +
+ +
+

🎯 Hábitos

+

Construa rotinas poderosas para transformação

+ +
+ +
+

💪 Saúde

+

Acompanhe peso, medidas e evolução física

+ +
+ +
+

📈 Progresso

+

Visualize sua jornada de transformação

+ +
+
+
+
+
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/pages/Habits.css b/frontend/src/pages/Habits.css new file mode 100644 index 0000000..3450ed4 --- /dev/null +++ b/frontend/src/pages/Habits.css @@ -0,0 +1,293 @@ +.habits-page { + min-height: 100vh; + background: url('../background-internal.jpg') center/cover no-repeat fixed; + position: relative; + padding: 2rem; +} + +.habits-page::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.4); + z-index: 0; +} + +.habits-header { + position: relative; + z-index: 1; + max-width: 1000px; + margin: 0 auto 2rem; + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(255, 255, 255, 0.95); + padding: 1.5rem; + border-radius: 16px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); +} + +.habits-header h1 { + font-size: 2rem; + color: #059669; + margin: 0; +} + +.back-button { + padding: 0.75rem 1.5rem; + background: #6b7280; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; +} + +.back-button:hover { + background: #4b5563; + transform: translateY(-2px); +} + +.add-button { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + border-radius: 8px; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; +} + +.add-button:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(16, 185, 129, 0.4); +} + +.habits-stats { + position: relative; + z-index: 1; + max-width: 1000px; + margin: 0 auto 2rem; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.habits-stats .stat { + background: rgba(255, 255, 255, 0.92); + padding: 1.5rem; + border-radius: 12px; + text-align: center; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); +} + +.stat-value { + display: block; + font-size: 2.5rem; + font-weight: 800; + color: #059669; +} + +.stat-label { + display: block; + font-size: 0.9rem; + color: #64748b; + margin-top: 0.5rem; +} + +.habits-list { + position: relative; + z-index: 1; + max-width: 1000px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.habit-card { + background: rgba(255, 255, 255, 0.92); + padding: 1.5rem; + border-radius: 16px; + display: flex; + align-items: center; + gap: 1rem; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + border: 2px solid transparent; +} + +.habit-card:hover { + transform: translateY(-3px); + box-shadow: 0 10px 30px rgba(16, 185, 129, 0.2); + border-color: #10b981; +} + +.habit-checkbox input[type="checkbox"] { + width: 30px; + height: 30px; + cursor: pointer; + accent-color: #10b981; +} + +.habit-content { + flex: 1; +} + +.habit-content h3 { + margin: 0 0 0.5rem 0; + color: #1a202c; + font-size: 1.25rem; +} + +.habit-content p { + margin: 0 0 0.5rem 0; + color: #64748b; + font-size: 0.9rem; +} + +.habit-frequency { + display: inline-block; + padding: 0.25rem 0.75rem; + background: #e0f2fe; + color: #0369a1; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.delete-button { + padding: 0.5rem 1rem; + background: #fee; + border: 2px solid #ef4444; + border-radius: 8px; + font-size: 1.25rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.delete-button:hover { + background: #ef4444; + transform: scale(1.1); +} + +.empty-state { + background: rgba(255, 255, 255, 0.92); + padding: 3rem; + border-radius: 16px; + text-align: center; + color: #64748b; +} + +.empty-state p { + margin: 0.5rem 0; + font-size: 1.1rem; +} + +.loading { + text-align: center; + font-size: 1.5rem; + color: #059669; + padding: 3rem; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + background: white; + padding: 2rem; + border-radius: 20px; + max-width: 500px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.modal-content h2 { + margin: 0 0 1.5rem 0; + color: #059669; +} + +.modal-content .form-group { + margin-bottom: 1.5rem; +} + +.modal-content label { + display: block; + font-weight: 600; + color: #2d3748; + margin-bottom: 0.5rem; +} + +.modal-content input, +.modal-content textarea, +.modal-content select { + width: 100%; + padding: 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + font-family: 'Poppins', sans-serif; +} + +.modal-content input:focus, +.modal-content textarea:focus, +.modal-content select:focus { + outline: none; + border-color: #10b981; +} + +.modal-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +.cancel-button { + padding: 0.75rem 1.5rem; + background: #e5e7eb; + color: #374151; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; +} + +.create-button { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + border-radius: 8px; + font-weight: 700; + cursor: pointer; +} + +@media (max-width: 768px) { + .habits-header { + flex-direction: column; + gap: 1rem; + } + + .habits-stats { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/Habits.tsx b/frontend/src/pages/Habits.tsx new file mode 100644 index 0000000..560ef0c --- /dev/null +++ b/frontend/src/pages/Habits.tsx @@ -0,0 +1,265 @@ +import React, { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import './Habits.css'; + +interface Habit { + id: string; + name: string; + description?: string; + frequency_type: string; + target_count: number; + is_active: boolean; + start_date: string; +} + +const Habits: React.FC<{ onBack: () => void }> = ({ onBack }) => { + const { token, user } = useAuth(); + const [habits, setHabits] = useState([]); + const [completedToday, setCompletedToday] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [creating, setCreating] = useState(false); + const [newHabit, setNewHabit] = useState({ + name: '', + description: '', + frequency_type: 'daily', + target_count: 1 + }); + + const backendUrl = `${window.location.protocol}//${window.location.host}`; + + useEffect(() => { + loadHabits(); + }, []); + + const loadHabits = async () => { + try { + const response = await fetch(`${backendUrl}/api/v1/habits/`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setHabits(data); + } + } catch (error) { + console.error('Erro ao carregar hábitos:', error); + } finally { + setLoading(false); + } + }; + + const createHabit = async () => { + if (!newHabit.name.trim()) { + alert('Nome do hábito é obrigatório!'); + return; + } + + setCreating(true); + + try { + const response = await fetch(`${backendUrl}/api/v1/habits/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(newHabit) + }); + + if (response.ok) { + setNewHabit({ name: '', description: '', frequency_type: 'daily', target_count: 1 }); + setShowModal(false); + await loadHabits(); + alert('✅ Hábito criado com sucesso!'); + } else { + const error = await response.json(); + alert('❌ Erro: ' + (error.detail || 'Erro ao criar hábito')); + } + } catch (error) { + console.error('Erro ao criar hábito:', error); + alert('❌ Erro ao criar hábito'); + } finally { + setCreating(false); + } + }; + + const toggleComplete = async (habitId: string) => { + const isCompleted = completedToday.has(habitId); + + try { + const url = `${backendUrl}/api/v1/habits/${habitId}/complete`; + const response = await fetch(url, { + method: isCompleted ? 'DELETE' : 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const newCompleted = new Set(completedToday); + if (isCompleted) { + newCompleted.delete(habitId); + } else { + newCompleted.add(habitId); + } + setCompletedToday(newCompleted); + } + } catch (error) { + console.error('Erro ao marcar hábito:', error); + } + }; + + const deleteHabit = async (habitId: string) => { + if (!confirm('Tem certeza que deseja deletar este hábito?')) return; + + try { + const response = await fetch(`${backendUrl}/api/v1/habits/${habitId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + loadHabits(); + } + } catch (error) { + console.error('Erro ao deletar hábito:', error); + } + }; + + if (loading) { + return ( +
+
⏳ Carregando hábitos...
+
+ ); + } + + return ( +
+
+ +

🎯 Meus Hábitos

+ +
+ +
+
+ {habits.length} + Total +
+
+ {completedToday.size} + Completos Hoje +
+
+ {habits.length - completedToday.size} + Pendentes +
+
+ +
+ {habits.length === 0 ? ( +
+

📝 Nenhum hábito criado ainda.

+

Comece criando seu primeiro hábito!

+
+ ) : ( + habits.map(habit => ( +
+
+ toggleComplete(habit.id)} + /> +
+
+

{habit.name}

+ {habit.description &&

{habit.description}

} + {habit.frequency_type} +
+ +
+ )) + )} +
+ + {/* Modal de Criar Hábito */} + {showModal && ( +
!creating && setShowModal(false)}> +
e.stopPropagation()}> +

🎯 Novo Hábito

+ +
+ + setNewHabit({...newHabit, name: e.target.value})} + placeholder="Ex: Fazer exercícios" + autoFocus + disabled={creating} + /> +
+ +
+ +