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
|
|
@ -38,6 +38,8 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
from app.models import page_view as _page_view_models # noqa: F401
|
||||
from app.models import agent as _agent_models # noqa: F401
|
||||
from app.models import imobiliaria as _imobiliaria_models # noqa: F401
|
||||
from app.models import contact_config as _contact_config_models # noqa: F401
|
||||
from app.models import job_application as _job_application_models # noqa: F401
|
||||
|
||||
# JWT secret key — raises KeyError if not set (fail-fast on misconfiguration)
|
||||
app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]
|
||||
|
|
@ -54,6 +56,8 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
from app.routes.config import config_bp
|
||||
from app.routes.agents import agents_public_bp, agents_admin_bp
|
||||
from app.routes.version import version_bp
|
||||
from app.routes.contact_config import contact_config_bp
|
||||
from app.routes.jobs import jobs_public_bp, jobs_admin_bp
|
||||
|
||||
app.register_blueprint(homepage_bp)
|
||||
app.register_blueprint(properties_bp)
|
||||
|
|
@ -67,10 +71,14 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
app.register_blueprint(agents_public_bp)
|
||||
app.register_blueprint(agents_admin_bp)
|
||||
app.register_blueprint(version_bp)
|
||||
app.register_blueprint(contact_config_bp)
|
||||
app.register_blueprint(jobs_public_bp)
|
||||
app.register_blueprint(jobs_admin_bp)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
from flask import jsonify
|
||||
|
||||
try:
|
||||
db.session.execute(db.text("SELECT 1"))
|
||||
return jsonify({"status": "ok", "db": "ok"}), 200
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ from .saved_property import SavedProperty
|
|||
from .visit_request import VisitRequest
|
||||
from .boleto import Boleto
|
||||
from .page_view import PageView
|
||||
from .contact_config import ContactConfig
|
||||
|
|
|
|||
22
backend/app/models/contact_config.py
Normal file
22
backend/app/models/contact_config.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from app.extensions import db
|
||||
|
||||
|
||||
class ContactConfig(db.Model):
|
||||
__tablename__ = "contact_config"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
address_street = db.Column(db.String(200), nullable=True)
|
||||
address_neighborhood_city = db.Column(db.String(200), nullable=True)
|
||||
address_zip = db.Column(db.String(20), nullable=True)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
email = db.Column(db.String(254), nullable=True)
|
||||
business_hours = db.Column(db.Text, nullable=True)
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
onupdate=db.func.now(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactConfig id={self.id}>"
|
||||
|
|
@ -11,6 +11,8 @@ class HomepageConfig(db.Model):
|
|||
hero_cta_url = db.Column(db.String(200), nullable=False, default="/imoveis")
|
||||
featured_properties_limit = db.Column(db.Integer, nullable=False, default=6)
|
||||
hero_image_url = db.Column(db.String(512), nullable=True)
|
||||
hero_image_light_url = db.Column(db.String(512), nullable=True)
|
||||
hero_image_dark_url = db.Column(db.String(512), nullable=True)
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
|
|
|
|||
27
backend/app/models/job_application.py
Normal file
27
backend/app/models/job_application.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from app.extensions import db
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplication(db.Model):
|
||||
__tablename__ = "job_applications"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
role_interest = db.Column(db.String(100), nullable=False)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
file_name = db.Column(db.String(255), nullable=True)
|
||||
status = db.Column(db.String(50), nullable=False, default="pending")
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, server_default=db.func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<JobApplication id={self.id} email={self.email!r}>"
|
||||
|
|
@ -15,6 +15,10 @@ class ContactLead(db.Model):
|
|||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
source = db.Column(
|
||||
db.String(100), nullable=True
|
||||
) # contato | imovel | cadastro_residencia
|
||||
source_detail = db.Column(db.String(255), nullable=True) # ex: título do imóvel
|
||||
created_at = db.Column(
|
||||
db.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
|
@ -22,4 +26,4 @@ class ContactLead(db.Model):
|
|||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactLead id={self.id} email={self.email!r}>"
|
||||
return f"<ContactLead id={self.id} email={self.email!r} source={self.source!r}>"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -13,14 +13,51 @@ class PropertyBrief(BaseModel):
|
|||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PropertyCard(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
slug: str
|
||||
price: Optional[Decimal] = None
|
||||
city: Optional[str] = None
|
||||
neighborhood: Optional[str] = None
|
||||
cover_photo_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_property(cls, prop) -> "PropertyCard":
|
||||
cover = prop.photos[0].url if prop.photos else None
|
||||
city = prop.city.name if prop.city else None
|
||||
neighborhood = prop.neighborhood.name if prop.neighborhood else None
|
||||
return cls(
|
||||
id=str(prop.id),
|
||||
title=prop.title,
|
||||
slug=prop.slug,
|
||||
price=prop.price,
|
||||
city=city,
|
||||
neighborhood=neighborhood,
|
||||
cover_photo_url=cover,
|
||||
)
|
||||
|
||||
|
||||
class SavedPropertyOut(BaseModel):
|
||||
id: str
|
||||
property_id: Optional[str]
|
||||
property: Optional[PropertyBrief]
|
||||
property: Optional[PropertyCard]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_saved(cls, saved) -> "SavedPropertyOut":
|
||||
prop_card = PropertyCard.from_property(saved.property) if saved.property else None
|
||||
return cls(
|
||||
id=str(saved.id),
|
||||
property_id=str(saved.property_id) if saved.property_id else None,
|
||||
property=prop_card,
|
||||
created_at=saved.created_at,
|
||||
)
|
||||
|
||||
|
||||
class FavoriteIn(BaseModel):
|
||||
property_id: str
|
||||
|
|
@ -93,3 +130,34 @@ class BoletoCreateIn(BaseModel):
|
|||
if v <= 0:
|
||||
raise ValueError("Valor deve ser positivo")
|
||||
return v
|
||||
|
||||
|
||||
class UpdateProfileIn(BaseModel):
|
||||
name: str
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class UpdateProfileOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdatePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("A nova senha deve ter pelo menos 8 caracteres")
|
||||
return v
|
||||
|
|
|
|||
43
backend/app/schemas/contact_config.py
Normal file
43
backend/app/schemas/contact_config.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
class ContactConfigOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address_street: str | None = None
|
||||
address_neighborhood_city: str | None = None
|
||||
address_zip: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
business_hours: str | None = None
|
||||
|
||||
|
||||
class ContactConfigIn(BaseModel):
|
||||
address_street: str | None = None
|
||||
address_neighborhood_city: str | None = None
|
||||
address_zip: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
business_hours: str | None = None
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
import re
|
||||
|
||||
v = v.strip().lower()
|
||||
if v and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", v):
|
||||
raise ValueError("E-mail inválido.")
|
||||
return v or None
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def validate_phone(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
return v or None
|
||||
|
|
@ -12,6 +12,8 @@ class HomepageConfigOut(BaseModel):
|
|||
hero_cta_url: str
|
||||
featured_properties_limit: int
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
|
||||
class HomepageConfigIn(BaseModel):
|
||||
|
|
@ -21,6 +23,8 @@ class HomepageConfigIn(BaseModel):
|
|||
hero_cta_url: str = "/imoveis"
|
||||
featured_properties_limit: int = 6
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
@field_validator("hero_headline")
|
||||
@classmethod
|
||||
|
|
@ -35,3 +39,17 @@ class HomepageConfigIn(BaseModel):
|
|||
if not (1 <= v <= 12):
|
||||
raise ValueError("featured_properties_limit deve estar entre 1 e 12")
|
||||
return v
|
||||
|
||||
|
||||
class HomepageHeroImagesIn(BaseModel):
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
@field_validator("hero_image_url", "hero_image_light_url", "hero_image_dark_url")
|
||||
@classmethod
|
||||
def normalize_empty_to_none(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
trimmed = v.strip()
|
||||
return trimmed or None
|
||||
|
|
|
|||
59
backend/app/schemas/job_application.py
Normal file
59
backend/app/schemas/job_application.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplicationIn(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
phone: Optional[str] = None
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: Optional[str] = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v
|
||||
|
||||
@field_validator("role_interest")
|
||||
@classmethod
|
||||
def valid_role(cls, v: str) -> str:
|
||||
if v not in ROLE_INTEREST_OPTIONS:
|
||||
raise ValueError(f"Cargo inválido. Opções: {ROLE_INTEREST_OPTIONS}")
|
||||
return v
|
||||
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def message_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Mensagem não pode ser vazia")
|
||||
if len(v) > 5000:
|
||||
raise ValueError("Mensagem não pode ultrapassar 5000 caracteres")
|
||||
return v
|
||||
|
||||
|
||||
class JobApplicationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: Optional[str]
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: Optional[str]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
|
@ -13,6 +13,8 @@ class ContactLeadIn(BaseModel):
|
|||
email: str
|
||||
phone: str | None = None
|
||||
message: str
|
||||
source: str | None = None
|
||||
source_detail: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ from typing import Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.catalog import AmenityOut, ImobiliariaOut, PropertyTypeOut, CityOut, NeighborhoodOut
|
||||
from app.schemas.catalog import (
|
||||
AmenityOut,
|
||||
ImobiliariaOut,
|
||||
PropertyTypeOut,
|
||||
CityOut,
|
||||
NeighborhoodOut,
|
||||
)
|
||||
|
||||
|
||||
class PropertyPhotoOut(BaseModel):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue