feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- 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:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
14
backend/app/routes/contact_config.py
Normal file
14
backend/app/routes/contact_config.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
56
backend/app/routes/jobs.py
Normal file
56
backend/app/routes/jobs.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue