feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s

- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
  preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
  keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint

migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
            i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
MatheusAlves96 2026-04-22 22:35:17 -03:00
parent 6ef5a7a17e
commit cf5603243c
106 changed files with 11927 additions and 1367 deletions

View file

@ -146,6 +146,7 @@ class PhotoAdminOut(BaseModel):
class PropertyAdminOut(BaseModel):
id: str
slug: str
title: str
code: Optional[str] = None
address: Optional[str] = None
@ -172,6 +173,7 @@ class PropertyAdminOut(BaseModel):
def from_prop(cls, p: Property) -> "PropertyAdminOut":
return cls(
id=str(p.id),
slug=p.slug,
title=p.title,
code=p.code,
address=p.address,
@ -213,6 +215,8 @@ def admin_list_properties():
q = request.args.get("q", "").strip()
city_id = request.args.get("city_id", type=int)
neighborhood_id = request.args.get("neighborhood_id", type=int)
type_filter = request.args.get("type") # 'venda' | 'aluguel' | None
is_active_raw = request.args.get("is_active") # 'true' | 'false' | None
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(50, max(1, int(request.args.get("per_page", 12))))
@ -231,6 +235,12 @@ def admin_list_properties():
query = query.filter(Property.city_id == city_id)
if neighborhood_id:
query = query.filter(Property.neighborhood_id == neighborhood_id)
if type_filter in ("venda", "aluguel"):
query = query.filter(Property.type == type_filter)
if is_active_raw == "true":
query = query.filter(Property.is_active.is_(True))
elif is_active_raw == "false":
query = query.filter(Property.is_active.is_(False))
total = query.count()
props = (
@ -973,17 +983,16 @@ def list_leads():
page = max(1, request.args.get("page", 1, type=int))
per_page = min(100, max(1, request.args.get("per_page", 20, type=int)))
property_id = request.args.get("property_id")
source = request.args.get("source")
q = db.select(ContactLead).order_by(ContactLead.created_at.desc())
if property_id:
q = q.where(ContactLead.property_id == property_id)
if source:
q = q.where(ContactLead.source == source)
total = db.session.scalar(
db.select(db.func.count()).select_from(q.subquery())
)
leads = db.session.scalars(
q.limit(per_page).offset((page - 1) * per_page)
).all()
total = db.session.scalar(db.select(db.func.count()).select_from(q.subquery()))
leads = db.session.scalars(q.limit(per_page).offset((page - 1) * per_page)).all()
return jsonify(
{
@ -995,6 +1004,8 @@ def list_leads():
"email": lead.email,
"phone": lead.phone,
"message": lead.message,
"source": lead.source,
"source_detail": lead.source_detail,
"created_at": lead.created_at.isoformat(),
}
for lead in leads
@ -1005,3 +1016,46 @@ def list_leads():
"pages": max(1, -(-total // per_page)),
}
)
# ─── Contact config ───────────────────────────────────────────────────────────
@admin_bp.get("/contact-config")
@require_admin
def get_contact_config_admin():
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigOut
config = ContactConfig.query.first()
if config is None:
return jsonify({}), 200
return jsonify(ContactConfigOut.model_validate(config).model_dump())
@admin_bp.put("/contact-config")
@require_admin
def update_contact_config():
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigIn, ContactConfigOut
data = request.get_json(silent=True) or {}
try:
cfg_in = ContactConfigIn.model_validate(data)
except ValidationError as exc:
return jsonify({"error": "Dados inválidos", "details": exc.errors()}), 422
config = ContactConfig.query.first()
if config is None:
config = ContactConfig(id=1)
db.session.add(config)
config.address_street = cfg_in.address_street
config.address_neighborhood_city = cfg_in.address_neighborhood_city
config.address_zip = cfg_in.address_zip
config.phone = cfg_in.phone
config.email = cfg_in.email
config.business_hours = cfg_in.business_hours
db.session.commit()
return jsonify(ContactConfigOut.model_validate(config).model_dump())

View file

@ -1,17 +1,24 @@
import uuid as _uuid
import bcrypt
from flask import Blueprint, request, jsonify, g
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app import db
from app.models.saved_property import SavedProperty
from app.models.visit_request import VisitRequest
from app.models.boleto import Boleto
from app.models.property import Property
from app.models.user import ClientUser
from app.schemas.client_area import (
SavedPropertyOut,
PropertyCard,
FavoriteIn,
VisitRequestOut,
BoletoOut,
UpdateProfileIn,
UpdateProfileOut,
UpdatePasswordIn,
)
from app.utils.auth import require_auth
@ -22,13 +29,15 @@ client_bp = Blueprint("client", __name__)
@require_auth
def get_favorites():
saved = (
SavedProperty.query.filter_by(user_id=g.current_user_id)
SavedProperty.query
.filter_by(user_id=g.current_user_id)
.options(selectinload(SavedProperty.property).selectinload(Property.photos))
.order_by(SavedProperty.created_at.desc())
.all()
)
return (
jsonify(
[SavedPropertyOut.model_validate(s).model_dump(mode="json") for s in saved]
[SavedPropertyOut.from_saved(s).model_dump(mode="json") for s in saved]
),
200,
)
@ -58,7 +67,7 @@ def add_favorite():
db.session.rollback()
return jsonify({"error": "Imóvel já está nos favoritos"}), 409
return jsonify(SavedPropertyOut.model_validate(saved).model_dump(mode="json")), 201
return jsonify(SavedPropertyOut.from_saved(saved).model_dump(mode="json")), 201
@client_bp.delete("/favorites/<property_id>")
@ -107,3 +116,64 @@ def get_boletos():
jsonify([BoletoOut.model_validate(b).model_dump(mode="json") for b in boletos]),
200,
)
@client_bp.patch("/profile")
@require_auth
def update_profile():
try:
data = UpdateProfileIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
user = db.session.get(ClientUser, g.current_user_id)
if not user:
return jsonify({"error": "Usuário não encontrado"}), 404
user.name = data.name
db.session.commit()
return jsonify(UpdateProfileOut.model_validate(user).model_dump(mode="json")), 200
@client_bp.patch("/password")
@require_auth
def change_password():
try:
data = UpdatePasswordIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
user = db.session.get(ClientUser, g.current_user_id)
if not user:
return jsonify({"error": "Usuário não encontrado"}), 404
if not bcrypt.checkpw(
data.current_password.encode("utf-8"),
user.password_hash.encode("utf-8"),
):
return jsonify({"error": "Senha atual incorreta"}), 400
user.password_hash = bcrypt.hashpw(
data.new_password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
db.session.commit()
return "", 204
@client_bp.patch("/visits/<visit_id>/cancel")
@require_auth
def cancel_visit(visit_id: str):
visit = db.session.get(VisitRequest, visit_id)
if not visit:
return jsonify({"error": "Visita não encontrada"}), 404
if str(visit.user_id) != str(g.current_user_id):
return jsonify({"error": "Acesso negado"}), 403
if visit.status != "pending":
return jsonify({"error": "Apenas visitas pendentes podem ser canceladas"}), 400
visit.status = "cancelled"
db.session.commit()
return jsonify(VisitRequestOut.model_validate(visit).model_dump(mode="json")), 200

View file

@ -0,0 +1,14 @@
from flask import Blueprint, jsonify
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigOut
contact_config_bp = Blueprint("contact_config", __name__, url_prefix="/api/v1")
@contact_config_bp.get("/contact-config")
def get_contact_config():
config = ContactConfig.query.first()
if config is None:
return jsonify({}), 200
return jsonify(ContactConfigOut.model_validate(config).model_dump())

View file

@ -1,7 +1,10 @@
from flask import Blueprint, jsonify
from flask import Blueprint, jsonify, request
from pydantic import ValidationError
from app.extensions import db
from app.models.homepage import HomepageConfig
from app.schemas.homepage import HomepageConfigOut
from app.schemas.homepage import HomepageConfigOut, HomepageHeroImagesIn
from app.utils.auth import require_admin
homepage_bp = Blueprint("homepage", __name__, url_prefix="/api/v1")
@ -11,4 +14,33 @@ def get_homepage_config():
config = HomepageConfig.query.first()
if config is None:
return jsonify({"error": "Homepage config not found"}), 404
return jsonify(HomepageConfigOut.model_validate(config).model_dump())
return jsonify(HomepageConfigOut.model_validate(config).model_dump(mode="json"))
@homepage_bp.put("/admin/homepage-config")
@require_admin
def update_homepage_hero_images():
try:
data = HomepageHeroImagesIn.model_validate(request.get_json() or {})
except ValidationError as e:
return jsonify({"error": e.errors(include_url=False)}), 422
config = HomepageConfig.query.first()
if config is None:
config = HomepageConfig(
hero_headline="Encontre o imóvel dos seus sonhos",
hero_subheadline="Os melhores imóveis para comprar ou alugar na sua região",
hero_cta_label="Ver Imóveis",
hero_cta_url="/imoveis",
featured_properties_limit=6,
)
db.session.add(config)
config.hero_image_url = data.hero_image_url
config.hero_image_light_url = data.hero_image_light_url
config.hero_image_dark_url = data.hero_image_dark_url
db.session.commit()
db.session.refresh(config)
return jsonify(HomepageConfigOut.model_validate(config).model_dump(mode="json")), 200

View file

@ -0,0 +1,56 @@
import json as _json
from flask import Blueprint, jsonify, request
from pydantic import ValidationError
from app.extensions import db
from app.models.job_application import JobApplication
from app.schemas.job_application import JobApplicationIn, JobApplicationOut
from app.utils.auth import require_admin
jobs_public_bp = Blueprint("jobs_public", __name__, url_prefix="/api/v1")
jobs_admin_bp = Blueprint("jobs_admin", __name__, url_prefix="/api/v1/admin")
@jobs_public_bp.route("/jobs/apply", methods=["POST"])
def apply():
data = request.get_json(silent=True) or {}
try:
validated = JobApplicationIn.model_validate(data)
except ValidationError as exc:
return jsonify({"error": "Dados inválidos", "details": _json.loads(exc.json())}), 422
application = JobApplication(
name=validated.name,
email=validated.email,
phone=validated.phone,
role_interest=validated.role_interest,
message=validated.message,
file_name=validated.file_name,
)
db.session.add(application)
db.session.commit()
return jsonify({"message": "Candidatura recebida com sucesso"}), 201
@jobs_admin_bp.route("/jobs", methods=["GET"])
@require_admin
def list_applications():
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 20, type=int), 100)
pagination = (
JobApplication.query
.order_by(JobApplication.created_at.desc())
.paginate(page=page, per_page=per_page, error_out=False)
)
items = [JobApplicationOut.model_validate(item).model_dump(mode="json") for item in pagination.items]
return jsonify({
"items": items,
"total": pagination.total,
"page": pagination.page,
"per_page": pagination.per_page,
"pages": pagination.pages,
}), 200

View file

@ -83,7 +83,9 @@ def list_properties():
neighborhood_ids_raw = args.get("neighborhood_ids", "")
if neighborhood_ids_raw:
try:
neighborhood_ids_list = [int(x) for x in neighborhood_ids_raw.split(",") if x.strip()]
neighborhood_ids_list = [
int(x) for x in neighborhood_ids_raw.split(",") if x.strip()
]
except ValueError:
neighborhood_ids_list = []
if neighborhood_ids_list:
@ -171,16 +173,14 @@ def list_properties():
if q_raw:
pattern = f"%{q_raw}%"
NeighborhoodAlias = aliased(Neighborhood)
query = (
query
.outerjoin(NeighborhoodAlias, Property.neighborhood_id == NeighborhoodAlias.id)
.filter(
or_(
Property.title.ilike(pattern),
Property.address.ilike(pattern),
Property.code.ilike(pattern),
NeighborhoodAlias.name.ilike(pattern),
)
query = query.outerjoin(
NeighborhoodAlias, Property.neighborhood_id == NeighborhoodAlias.id
).filter(
or_(
Property.title.ilike(pattern),
Property.address.ilike(pattern),
Property.code.ilike(pattern),
NeighborhoodAlias.name.ilike(pattern),
)
)
@ -203,10 +203,7 @@ def list_properties():
pages = math.ceil(total / per_page) if total > 0 else 1
props = (
query.order_by(sort_order)
.offset((page - 1) * per_page)
.limit(per_page)
.all()
query.order_by(sort_order).offset((page - 1) * per_page).limit(per_page).all()
)
result = PaginatedPropertiesOut(
@ -248,6 +245,46 @@ def contact_property(slug: str):
email=lead_in.email,
phone=lead_in.phone,
message=lead_in.message,
source=lead_in.source or "imovel",
source_detail=lead_in.source_detail or prop.title,
)
db.session.add(lead)
db.session.commit()
return (
jsonify(
ContactLeadCreatedOut(
id=lead.id, message="Mensagem enviada com sucesso!"
).model_dump()
),
201,
)
@properties_bp.post("/contact")
def contact_general():
"""Contato geral (página /contato e /cadastro-residencia)."""
from app.models.lead import ContactLead
from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut
data = request.get_json(silent=True) or {}
try:
lead_in = ContactLeadIn.model_validate(data)
except ValidationError as exc:
import json as _json
return jsonify({"error": "Dados inválidos", "details": _json.loads(exc.json())}), 422
valid_sources = {"contato", "imovel", "cadastro_residencia"}
source = lead_in.source if lead_in.source in valid_sources else "contato"
lead = ContactLead(
property_id=None,
name=lead_in.name,
email=lead_in.email,
phone=lead_in.phone,
message=lead_in.message,
source=source,
source_detail=lead_in.source_detail,
)
db.session.add(lead)
db.session.commit()