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

@ -1,6 +1,6 @@
# saas_imobiliaria Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-04-20
Auto-generated from all feature plans. Last updated: 2026-04-22
## Active Technologies
- Python 3.12 (backend) · TypeScript 5.5 (frontend) + Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT>=2.9, bcrypt>=4.2, pydantic[email]; React 18, react-router-dom v6, Axios (master)
@ -19,6 +19,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-20
- PostgreSQL 16 — sem novas tabelas ou migrations nesta feature (master)
- Python 3.12 (backend) · TypeScript 5.5 (frontend) + Flask 3.x, SQLAlchemy 2.x (func.count subquery), Pydantic v2 (backend) · React 18, Tailwind CSS 3.4 (frontend) (master)
- PostgreSQL 16 — sem novas tabelas; property_count calculado via outerjoin COUNT (feature 024)
- TypeScript 5.5 (frontend principal) / Python 3.12 existente sem mudança funcional + React 18, react-router-dom v6, Tailwind CSS 3.4, Axios (indireto via autenticação), contexto próprio `useAuth`, `useFavorites`, `ThemeToggle` (030-navbar-topo-ux)
- N/A para persistência nova; sessão e token continuam em `localStorage` via `AuthContext` (030-navbar-topo-ux)
- Python 3.12 / TypeScript 5.5 + Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend) (master)
@ -39,9 +41,9 @@ cd src; pytest; ruff check .
Python 3.12 / TypeScript 5.5: Follow standard conventions
## Recent Changes
- 030-navbar-topo-ux: Added TypeScript 5.5 (frontend principal) / Python 3.12 existente sem mudança funcional + React 18, react-router-dom v6, Tailwind CSS 3.4, Axios (indireto via autenticação), contexto próprio `useAuth`, `useFavorites`, `ThemeToggle`
- master: Added Python 3.12 + Flask SQLAlchemy func.count subquery · React 18 FilterSidebar com busca cross-categoria, controlled accordion, truncamento top-5 (feature 024)
- master: Added Python 3.12 (backend) · TypeScript 5.5 (frontend) + Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
- master: Added TypeScript 5.5 / React 18 + react-router-dom v6 (já instalado), Tailwind CSS 3.4 (já configurado)
<!-- MANUAL ADDITIONS START -->

View file

@ -1,3 +1,3 @@
{
"feature_directory": "specs/023-ux-melhorias-imoveis"
"feature_directory": "specs/029-ux-area-do-cliente"
}

View file

@ -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

View file

@ -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

View 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}>"

View file

@ -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,

View 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}>"

View file

@ -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}>"

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()

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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):

View file

@ -0,0 +1,32 @@
"""add source and source_detail to contact_leads
Revision ID: g1h2i3j4k5l6
Revises: f2a3b4c5d6e7
Create Date: 2026-04-21 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "g1h2i3j4k5l6"
down_revision = "f2a3b4c5d6e7"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"contact_leads",
sa.Column("source", sa.String(100), nullable=True),
)
op.add_column(
"contact_leads",
sa.Column("source_detail", sa.String(255), nullable=True),
)
def downgrade():
op.drop_column("contact_leads", "source_detail")
op.drop_column("contact_leads", "source")

View file

@ -0,0 +1,55 @@
"""create contact_config table
Revision ID: h1i2j3k4l5m6
Revises: g1h2i3j4k5l6
Create Date: 2026-04-21 00:01:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "h1i2j3k4l5m6"
down_revision = "g1h2i3j4k5l6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"contact_config",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("address_street", sa.String(200), nullable=True),
sa.Column("address_neighborhood_city", sa.String(200), nullable=True),
sa.Column("address_zip", sa.String(20), nullable=True),
sa.Column("phone", sa.String(30), nullable=True),
sa.Column("email", sa.String(254), nullable=True),
sa.Column("business_hours", sa.Text(), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)
# Seed inicial com os valores atualmente hardcoded na página de contato
op.execute(
"""
INSERT INTO contact_config (
id, address_street, address_neighborhood_city, address_zip,
phone, email, business_hours
) VALUES (
1,
'Rua das Imobiliárias, 123',
'Centro — São Paulo, SP',
'CEP 01000-000',
'(11) 99999-0000',
'contato@imobiliariahub.com.br',
'Segunda a sexta: 9h às 18h\nSábados: 9h às 13h\nDomingos e feriados: fechado'
)
"""
)
def downgrade():
op.drop_table("contact_config")

View file

@ -0,0 +1,44 @@
"""add job_applications table
Revision ID: i1j2k3l4m5n6
Revises: h1i2j3k4l5m6
Create Date: 2026-04-21 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "i1j2k3l4m5n6"
down_revision = "h1i2j3k4l5m6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"job_applications",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(150), nullable=False),
sa.Column("email", sa.String(254), nullable=False),
sa.Column("phone", sa.String(30), nullable=True),
sa.Column("role_interest", sa.String(100), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("file_name", sa.String(255), nullable=True),
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_job_applications_created_at", "job_applications", ["created_at"], unique=False)
op.create_index("ix_job_applications_status", "job_applications", ["status"], unique=False)
def downgrade():
op.drop_index("ix_job_applications_status", table_name="job_applications")
op.drop_index("ix_job_applications_created_at", table_name="job_applications")
op.drop_table("job_applications")

View file

@ -0,0 +1,31 @@
"""add homepage hero light/dark image urls
Revision ID: j2k3l4m5n6o7
Revises: i1j2k3l4m5n6
Create Date: 2026-04-22 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "j2k3l4m5n6o7"
down_revision = "i1j2k3l4m5n6"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"homepage_config",
sa.Column("hero_image_light_url", sa.String(length=512), nullable=True),
)
op.add_column(
"homepage_config",
sa.Column("hero_image_dark_url", sa.String(length=512), nullable=True),
)
def downgrade():
op.drop_column("homepage_config", "hero_image_dark_url")
op.drop_column("homepage_config", "hero_image_light_url")

View file

@ -694,6 +694,9 @@ def seed() -> None:
hero_cta_label="Ver Imóveis",
hero_cta_url="/imoveis",
featured_properties_limit=6,
hero_image_url="https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?auto=format&fit=crop&w=1920&q=80",
hero_image_light_url="https://images.unsplash.com/photo-1600585154526-990dced4db0d?auto=format&fit=crop&w=1920&q=80",
hero_image_dark_url="https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?auto=format&fit=crop&w=1920&q=80",
)
)
@ -775,6 +778,50 @@ def seed() -> None:
db.session.commit()
print(f"Admin: {ADMIN_EMAIL}")
# ── Generic admin (credenciais simples para demo) ────────────────────
GENERIC_ADMIN_EMAIL = "admin@demo.com"
GENERIC_ADMIN_PASSWORD = "admin1234"
gadmin = ClientUser.query.filter_by(email=GENERIC_ADMIN_EMAIL).first()
if not gadmin:
gadmin = ClientUser(
name="Admin Demo",
email=GENERIC_ADMIN_EMAIL,
password_hash=bcrypt.hashpw(
GENERIC_ADMIN_PASSWORD.encode(), bcrypt.gensalt()
).decode(),
role="admin",
)
db.session.add(gadmin)
else:
gadmin.password_hash = bcrypt.hashpw(
GENERIC_ADMIN_PASSWORD.encode(), bcrypt.gensalt()
).decode()
gadmin.role = "admin"
db.session.commit()
print(f"Generic admin: {GENERIC_ADMIN_EMAIL}")
# ── Demo user (sem acesso admin) ─────────────────────────────────────
DEMO_EMAIL = "usuario@demo.com"
DEMO_PASSWORD = "demo1234"
demo = ClientUser.query.filter_by(email=DEMO_EMAIL).first()
if not demo:
demo = ClientUser(
name="Usuário Demo",
email=DEMO_EMAIL,
password_hash=bcrypt.hashpw(
DEMO_PASSWORD.encode(), bcrypt.gensalt()
).decode(),
role="user",
)
db.session.add(demo)
else:
demo.password_hash = bcrypt.hashpw(
DEMO_PASSWORD.encode(), bcrypt.gensalt()
).decode()
demo.role = "user"
db.session.commit()
print(f"Demo user: {DEMO_EMAIL}")
total_amenities = sum(len(v) for v in AMENITIES.values())
total_types = sum(1 + len(c["subtypes"]) for c in PROPERTY_TYPES)
total_cities = len(LOCATIONS)

View file

@ -0,0 +1,420 @@
"""
Testes de integração fluxo completo de contato.
Cobre os três caminhos de submissão de lead:
1. POST /api/v1/contact (contato geral)
2. POST /api/v1/properties/<slug>/contact (contato de imóvel)
3. POST /api/v1/contact com source=cadastro_residencia
Além da visualização admin via:
- GET /api/v1/admin/leads
- GET /api/v1/admin/leads?source=<origem>
"""
import uuid
import jwt
from datetime import datetime, timedelta, timezone
import bcrypt
import pytest
from app.extensions import db as _db
from app.models.lead import ContactLead
from app.models.property import Property
from app.models.user import ClientUser
# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────
_TEST_JWT_SECRET = "test-secret-key"
_VALID_CONTACT = {
"name": "João da Silva",
"email": "joao@example.com",
"phone": "(11) 91234-5678",
"message": "Tenho interesse em anunciar meu imóvel.",
}
def _make_admin_token(user_id: str) -> str:
payload = {
"sub": user_id,
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, _TEST_JWT_SECRET, algorithm="HS256")
def _admin_headers(user_id: str) -> dict:
return {"Authorization": f"Bearer {_make_admin_token(user_id)}"}
def _make_property(slug: str) -> Property:
return Property(
id=uuid.uuid4(),
title=f"Apartamento {slug}",
slug=slug,
address="Rua Teste, 10",
price="350000.00",
type="venda",
bedrooms=2,
bathrooms=1,
area_m2=70,
is_featured=False,
is_active=True,
)
def _make_admin_user(db) -> ClientUser:
pwd_hash = bcrypt.hashpw(b"admin123", bcrypt.gensalt()).decode()
admin = ClientUser(
name="Admin Teste",
email="admin@test.com",
password_hash=pwd_hash,
role="admin",
)
db.session.add(admin)
db.session.flush()
return admin
# ──────────────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _patch_jwt_secret(app):
"""Garante que os testes usem o mesmo secret para gerar e validar tokens."""
app.config["JWT_SECRET_KEY"] = _TEST_JWT_SECRET
# ──────────────────────────────────────────────────────────────────────────────
# 1. POST /api/v1/contact — contato geral
# ──────────────────────────────────────────────────────────────────────────────
class TestContactGeneral:
def test_returns_201_with_id(self, client):
res = client.post("/api/v1/contact", json=_VALID_CONTACT)
assert res.status_code == 201
body = res.get_json()
assert "id" in body
assert body["message"] == "Mensagem enviada com sucesso!"
def test_lead_persisted_in_db(self, client, db):
client.post("/api/v1/contact", json=_VALID_CONTACT)
lead = db.session.query(ContactLead).filter_by(email="joao@example.com").first()
assert lead is not None
assert lead.name == "João da Silva"
assert lead.source == "contato"
def test_source_defaults_to_contato_when_absent(self, client, db):
payload = {**_VALID_CONTACT, "email": "sem_source@example.com"}
client.post("/api/v1/contact", json=payload)
lead = db.session.query(ContactLead).filter_by(email="sem_source@example.com").first()
assert lead.source == "contato"
def test_explicit_source_contato_is_preserved(self, client, db):
payload = {**_VALID_CONTACT, "email": "src_contato@example.com", "source": "contato"}
client.post("/api/v1/contact", json=payload)
lead = db.session.query(ContactLead).filter_by(email="src_contato@example.com").first()
assert lead.source == "contato"
def test_unknown_source_falls_back_to_contato(self, client, db):
payload = {**_VALID_CONTACT, "email": "bad_source@example.com", "source": "spam"}
client.post("/api/v1/contact", json=payload)
lead = db.session.query(ContactLead).filter_by(email="bad_source@example.com").first()
assert lead.source == "contato"
def test_missing_name_returns_422(self, client):
payload = {k: v for k, v in _VALID_CONTACT.items() if k != "name"}
res = client.post("/api/v1/contact", json=payload)
assert res.status_code == 422
def test_invalid_email_returns_422(self, client):
payload = {**_VALID_CONTACT, "email": "not-an-email"}
res = client.post("/api/v1/contact", json=payload)
assert res.status_code == 422
def test_missing_message_returns_422(self, client):
payload = {k: v for k, v in _VALID_CONTACT.items() if k != "message"}
res = client.post("/api/v1/contact", json=payload)
assert res.status_code == 422
# ──────────────────────────────────────────────────────────────────────────────
# 2. POST /api/v1/contact com source=cadastro_residencia
# ──────────────────────────────────────────────────────────────────────────────
class TestContactCadastroResidencia:
def test_source_cadastro_residencia_is_saved(self, client, db):
payload = {
**_VALID_CONTACT,
"email": "captacao@example.com",
"source": "cadastro_residencia",
"source_detail": "Apartamento",
}
res = client.post("/api/v1/contact", json=payload)
assert res.status_code == 201
lead = db.session.query(ContactLead).filter_by(email="captacao@example.com").first()
assert lead is not None
assert lead.source == "cadastro_residencia"
assert lead.source_detail == "Apartamento"
assert lead.property_id is None
# ──────────────────────────────────────────────────────────────────────────────
# 3. POST /api/v1/properties/<slug>/contact — contato de imóvel
# ──────────────────────────────────────────────────────────────────────────────
class TestContactProperty:
def test_returns_201_and_persists_lead(self, client, db):
prop = _make_property("apto-centro-test")
db.session.add(prop)
db.session.commit()
res = client.post("/api/v1/properties/apto-centro-test/contact", json=_VALID_CONTACT)
assert res.status_code == 201
lead = db.session.query(ContactLead).filter_by(email="joao@example.com").first()
assert lead is not None
assert lead.property_id == prop.id
assert lead.source == "imovel"
assert lead.source_detail == prop.title
def test_returns_404_for_unknown_slug(self, client):
res = client.post("/api/v1/properties/slug-inexistente/contact", json=_VALID_CONTACT)
assert res.status_code == 404
def test_inactive_property_returns_404(self, client, db):
prop = _make_property("apto-inativo")
prop.is_active = False
db.session.add(prop)
db.session.commit()
res = client.post("/api/v1/properties/apto-inativo/contact", json=_VALID_CONTACT)
assert res.status_code == 404
# ──────────────────────────────────────────────────────────────────────────────
# 4. GET /api/v1/admin/leads — visualização admin
# ──────────────────────────────────────────────────────────────────────────────
class TestAdminLeads:
def test_requires_authentication(self, client):
res = client.get("/api/v1/admin/leads")
assert res.status_code == 401
def test_non_admin_user_gets_403(self, client, db):
pwd_hash = bcrypt.hashpw(b"user123", bcrypt.gensalt()).decode()
user = ClientUser(
name="Usuário Comum",
email="comum@test.com",
password_hash=pwd_hash,
role="client",
)
db.session.add(user)
db.session.flush()
res = client.get(
"/api/v1/admin/leads",
headers=_admin_headers(user.id),
)
assert res.status_code == 403
def test_admin_sees_all_leads(self, client, db):
admin = _make_admin_user(db)
# Cria 3 leads via API para garantir persistência realista
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "a1@ex.com", "source": "contato"})
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "a2@ex.com", "source": "cadastro_residencia"})
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "a3@ex.com", "source": "cadastro_residencia"})
res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
assert res.status_code == 200
body = res.get_json()
assert "items" in body
assert "total" in body
assert body["total"] >= 3
def test_response_has_required_fields(self, client, db):
admin = _make_admin_user(db)
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "fields@ex.com"})
res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
item = res.get_json()["items"][0]
for field in ("id", "name", "email", "phone", "message", "source", "source_detail", "created_at"):
assert field in item, f"Campo ausente na resposta: {field}"
def test_filter_by_source_contato(self, client, db):
admin = _make_admin_user(db)
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "f_contato@ex.com", "source": "contato"})
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "f_captacao@ex.com", "source": "cadastro_residencia"})
res = client.get("/api/v1/admin/leads?source=contato", headers=_admin_headers(admin.id))
body = res.get_json()
sources = {item["source"] for item in body["items"]}
assert sources == {"contato"}
def test_filter_by_source_cadastro_residencia(self, client, db):
admin = _make_admin_user(db)
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "cr1@ex.com", "source": "cadastro_residencia"})
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "cr2@ex.com", "source": "contato"})
res = client.get("/api/v1/admin/leads?source=cadastro_residencia", headers=_admin_headers(admin.id))
body = res.get_json()
assert all(item["source"] == "cadastro_residencia" for item in body["items"])
def test_filter_by_source_imovel(self, client, db):
admin = _make_admin_user(db)
prop = _make_property("apto-filtro-imovel")
db.session.add(prop)
db.session.commit()
client.post("/api/v1/properties/apto-filtro-imovel/contact", json={**_VALID_CONTACT, "email": "imovel_f@ex.com"})
client.post("/api/v1/contact", json={**_VALID_CONTACT, "email": "contato_f@ex.com", "source": "contato"})
res = client.get("/api/v1/admin/leads?source=imovel", headers=_admin_headers(admin.id))
body = res.get_json()
assert all(item["source"] == "imovel" for item in body["items"])
def test_pagination_defaults(self, client, db):
admin = _make_admin_user(db)
res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
body = res.get_json()
assert body["page"] == 1
assert body["per_page"] == 20
assert "pages" in body
def test_pagination_page_2(self, client, db):
admin = _make_admin_user(db)
# Cria 25 leads
for i in range(25):
client.post(
"/api/v1/contact",
json={**_VALID_CONTACT, "email": f"page_test_{i}@ex.com"},
)
res_p1 = client.get("/api/v1/admin/leads?per_page=10&page=1", headers=_admin_headers(admin.id))
res_p2 = client.get("/api/v1/admin/leads?per_page=10&page=2", headers=_admin_headers(admin.id))
assert len(res_p1.get_json()["items"]) == 10
assert len(res_p2.get_json()["items"]) == 10
ids_p1 = {item["id"] for item in res_p1.get_json()["items"]}
ids_p2 = {item["id"] for item in res_p2.get_json()["items"]}
assert ids_p1.isdisjoint(ids_p2), "Páginas não devem ter leads em comum"
# ──────────────────────────────────────────────────────────────────────────────
# 5. Fluxo end-to-end completo
# ──────────────────────────────────────────────────────────────────────────────
class TestEndToEndContactFlow:
"""Valida o caminho completo: submissão pública → visualização admin."""
def test_property_contact_appears_in_admin_with_correct_metadata(self, client, db):
admin = _make_admin_user(db)
prop = _make_property("apto-e2e-flow")
db.session.add(prop)
db.session.commit()
# 1. Cliente envia contato pelo imóvel
submit_res = client.post(
"/api/v1/properties/apto-e2e-flow/contact",
json={
"name": "Maria Souza",
"email": "maria@example.com",
"phone": "(21) 98765-4321",
"message": "Quero agendar uma visita.",
},
)
assert submit_res.status_code == 201
lead_id = submit_res.get_json()["id"]
# 2. Admin lista todos os leads e localiza o criado
list_res = client.get("/api/v1/admin/leads", headers=_admin_headers(admin.id))
assert list_res.status_code == 200
items = list_res.get_json()["items"]
match = next((item for item in items if item["id"] == lead_id), None)
assert match is not None, "Lead não encontrado na listagem admin"
assert match["name"] == "Maria Souza"
assert match["email"] == "maria@example.com"
assert match["phone"] == "(21) 98765-4321"
assert match["message"] == "Quero agendar uma visita."
assert match["source"] == "imovel"
assert match["source_detail"] == prop.title
assert match["property_id"] == str(prop.id)
assert match["created_at"] is not None
def test_general_contact_appears_in_admin_without_property_id(self, client, db):
admin = _make_admin_user(db)
# 1. Cliente envia contato geral
submit_res = client.post(
"/api/v1/contact",
json={
"name": "Carlos Lima",
"email": "carlos@example.com",
"phone": "(31) 97654-3210",
"message": "Quero informações sobre venda.",
"source": "contato",
},
)
assert submit_res.status_code == 201
lead_id = submit_res.get_json()["id"]
# 2. Admin filtra por source=contato e encontra o lead
list_res = client.get(
"/api/v1/admin/leads?source=contato",
headers=_admin_headers(admin.id),
)
items = list_res.get_json()["items"]
match = next((item for item in items if item["id"] == lead_id), None)
assert match is not None
assert match["property_id"] is None
assert match["source"] == "contato"
def test_cadastro_residencia_appears_in_admin_with_source_detail(self, client, db):
admin = _make_admin_user(db)
# 1. Cliente submete formulário de captação
submit_res = client.post(
"/api/v1/contact",
json={
"name": "Ana Paula",
"email": "ana@example.com",
"phone": "(41) 96543-2109",
"message": "Finalidade: Venda\nTipo: Casa\nValor: R$ 800.000",
"source": "cadastro_residencia",
"source_detail": "Casa",
},
)
assert submit_res.status_code == 201
lead_id = submit_res.get_json()["id"]
# 2. Admin filtra por source=cadastro_residencia
list_res = client.get(
"/api/v1/admin/leads?source=cadastro_residencia",
headers=_admin_headers(admin.id),
)
items = list_res.get_json()["items"]
match = next((item for item in items if item["id"] == lead_id), None)
assert match is not None
assert match["source"] == "cadastro_residencia"
assert match["source_detail"] == "Casa"
assert match["property_id"] is None
assert "Venda" in match["message"]

View file

@ -1,5 +1,5 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import AdminRoute from './components/AdminRoute';
import ComparisonBar from './components/ComparisonBar';
import ProtectedRoute from './components/ProtectedRoute';
@ -8,28 +8,35 @@ import { ComparisonProvider } from './contexts/ComparisonContext';
import { FavoritesProvider } from './contexts/FavoritesContext';
import AdminLayout from './layouts/AdminLayout';
import ClientLayout from './layouts/ClientLayout';
import AboutPage from './pages/AboutPage';
import AgentsPage from './pages/AgentsPage';
import CadastroResidenciaPage from './pages/CadastroResidenciaPage'
import JobsPage from './pages/JobsPage';
import ContactPage from './pages/ContactPage';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
import PropertiesPage from './pages/PropertiesPage';
import PropertyDetailPage from './pages/PropertyDetailPage';
import PublicFavoritesPage from './pages/PublicFavoritesPage';
import RegisterPage from './pages/RegisterPage';
import AgentsPage from './pages/AgentsPage';
import AboutPage from './pages/AboutPage';
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
import AdminAmenitiesPage from './pages/admin/AdminAmenitiesPage';
import AdminAgentsPage from './pages/admin/AdminAgentsPage';
import AdminAmenitiesPage from './pages/admin/AdminAmenitiesPage';
import AdminAnalyticsPage from './pages/admin/AdminAnalyticsPage';
import AdminBoletosPage from './pages/admin/AdminBoletosPage';
import AdminCitiesPage from './pages/admin/AdminCitiesPage';
import AdminClientesPage from './pages/admin/AdminClientesPage';
import AdminContactConfigPage from './pages/admin/AdminContactConfigPage';
import AdminFavoritosPage from './pages/admin/AdminFavoritosPage';
import AdminLeadsPage from './pages/admin/AdminLeadsPage'
import AdminJobsPage from './pages/admin/AdminJobsPage';
import AdminPropertiesPage from './pages/admin/AdminPropertiesPage';
import AdminVisitasPage from './pages/admin/AdminVisitasPage';
import BoletosPage from './pages/client/BoletosPage';
import ClientDashboardPage from './pages/client/ClientDashboardPage';
import AdminHomepageConfigPage from './pages/admin/AdminHomepageConfigPage';
import ComparisonPage from './pages/client/ComparisonPage';
import FavoritesPage from './pages/client/FavoritesPage';
import VisitsPage from './pages/client/VisitsPage';
import ProfilePage from './pages/client/ProfilePage';
export default function App() {
return (
@ -44,6 +51,10 @@ export default function App() {
<Route path="/corretores" element={<AgentsPage />} />
<Route path="/sobre" element={<AboutPage />} />
<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />
<Route path="/favoritos" element={<PublicFavoritesPage />} />
<Route path="/contato" element={<ContactPage />} />
<Route path="/cadastro-residencia" element={<CadastroResidenciaPage />} />
<Route path="/trabalhe-conosco" element={<JobsPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/cadastro" element={<RegisterPage />} />
<Route
@ -54,11 +65,11 @@ export default function App() {
</ProtectedRoute>
}
>
<Route index element={<ClientDashboardPage />} />
<Route index element={<Navigate to="favoritos" replace />} />
<Route path="favoritos" element={<FavoritesPage />} />
<Route path="comparar" element={<ComparisonPage />} />
<Route path="visitas" element={<VisitsPage />} />
<Route path="boletos" element={<BoletosPage />} />
<Route path="conta" element={<ProfilePage />} />
</Route>
<Route
path="/admin"
@ -78,6 +89,10 @@ export default function App() {
<Route path="amenidades" element={<AdminAmenitiesPage />} />
<Route path="corretores" element={<AdminAgentsPage />} />
<Route path="analytics" element={<AdminAnalyticsPage />} />
<Route path="leads" element={<AdminLeadsPage />} />
<Route path="candidaturas" element={<AdminJobsPage />} />
<Route path="contato-config" element={<AdminContactConfigPage />} />
<Route path="homepage-config" element={<AdminHomepageConfigPage />} />
</Route>
</Routes>
<ComparisonBar />

View file

@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { getAgents } from '../services/agents'
import type { Agent } from '../types/agent'
const AUTOPLAY_INTERVAL = 3500
@ -76,21 +75,17 @@ function SkeletonSlide() {
)
}
export default function AgentsCarousel() {
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
interface AgentsCarouselProps {
agents: Agent[]
loading: boolean
}
export default function AgentsCarousel({ agents, loading }: AgentsCarouselProps) {
const [current, setCurrent] = useState(0)
const autoplayRef = useRef<ReturnType<typeof setInterval> | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const [paused, setPaused] = useState(false)
useEffect(() => {
getAgents()
.then(setAgents)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Duplicate agents for infinite-like feel
const slides = agents.length > 0 ? [...agents, ...agents] : []
const total = agents.length
@ -130,7 +125,7 @@ export default function AgentsCarousel() {
<div
ref={trackRef}
className="flex gap-5 transition-transform duration-500 ease-in-out py-2"
style={{ transform: `translateX(-${offset}px)` }}
style={{ transform: `translateX(-${offset}px)`, willChange: 'transform' }}
>
{slides.map((agent, i) => (
<AgentSlide key={`${agent.id}-${i}`} agent={agent} />

View file

@ -0,0 +1,95 @@
import { Link } from 'react-router-dom';
import HeartButton from './HeartButton';
export interface FavoriteCardEntry {
id: string;
slug: string;
title: string;
price: string;
type: 'venda' | 'aluguel';
photo: string | null;
city: string | null;
bedrooms: number;
area_m2: number;
}
function formatPrice(price: string, type: 'venda' | 'aluguel') {
const num = parseFloat(price);
if (isNaN(num)) return price;
const formatted = num.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 });
return type === 'aluguel' ? `${formatted}/mês` : formatted;
}
interface FavoritesCardsGridProps {
entries: FavoriteCardEntry[];
}
export default function FavoritesCardsGrid({ entries }: FavoritesCardsGridProps) {
if (entries.length === 0) {
return (
<div className="rounded-xl border border-borderSubtle bg-panel p-16 text-center">
<svg className="mx-auto mb-4 text-textTertiary" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
<p className="text-textTertiary mb-4">Nenhum favorito ainda</p>
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
Explorar imóveis
</Link>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{entries.map(entry => (
<div
key={entry.id}
className="relative rounded-xl border border-borderSubtle bg-panel overflow-hidden hover:border-borderStandard transition group"
>
<div className="relative h-40 bg-surface">
{entry.photo ? (
<img
src={entry.photo}
alt={entry.title}
className="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-textTertiary">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" />
</svg>
</div>
)}
<div className="absolute top-2 right-2 z-10">
<HeartButton propertyId={entry.id} />
</div>
<span className={`absolute bottom-2 left-2 rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm ${entry.type === 'venda' ? 'bg-brand/80 text-white' : 'bg-black/50 text-white/90 border border-white/20'}`}>
{entry.type === 'venda' ? 'Venda' : 'Aluguel'}
</span>
</div>
<div className="p-4">
<Link to={entry.slug ? `/imoveis/${entry.slug}` : '#'} className="block">
<p className="text-sm font-semibold text-textPrimary line-clamp-2 leading-snug">
{entry.title}
</p>
{entry.city && (
<p className="text-xs text-textTertiary mt-1 truncate">{entry.city}</p>
)}
{entry.price && (
<p className="text-sm font-semibold text-textPrimary mt-2">
{formatPrice(entry.price, entry.type)}
</p>
)}
<div className="flex gap-3 mt-2 text-xs text-textTertiary">
{entry.bedrooms > 0 && <span>{entry.bedrooms} qto{entry.bedrooms !== 1 ? 's' : ''}</span>}
{entry.area_m2 > 0 && <span>{entry.area_m2} m²</span>}
</div>
</Link>
</div>
</div>
))}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { PropertyFilters } from '../services/properties'
import type { Amenity, AmenityGroup, City, Imobiliaria, Neighborhood, PropertyType } from '../types/catalog'
@ -406,7 +406,7 @@ function PriceRange({
className={`h-7 px-2.5 rounded-md text-xs border transition-all duration-150 ${maxValue === p.value
? 'bg-brand/15 border-brand/40 text-brand font-medium'
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
}`}
}`}
>
{p.label}
</button>
@ -490,7 +490,7 @@ function MinChipRow({
className={`h-8 min-w-[40px] px-2.5 rounded-lg text-xs font-medium border transition-all duration-150 ${isActive
? 'bg-brand/15 border-brand/40 text-brand'
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
}`}
}`}
>
{opt}{suffix}
</button>
@ -510,7 +510,7 @@ function AmenityCheck({ name, checked, onToggle }: { name: string; checked: bool
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-all duration-150 ${checked
? 'bg-brand border-brand'
: 'bg-transparent border-borderStandard group-hover/item:border-brand/50'
}`}
}`}
aria-hidden="true"
>
{checked && (
@ -697,7 +697,7 @@ export default function FilterSidebar({
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 ${isActive
? 'bg-panel text-textPrimary shadow-sm'
: 'text-textTertiary hover:text-textSecondary'
}`}
}`}
>
{label}
</button>
@ -733,7 +733,7 @@ export default function FilterSidebar({
open={openSections['imobiliaria']}
onToggle={() => toggleSection('imobiliaria')}
>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-col gap-0.5">
{imobiliarias.map(imob => {
const isActive = filters.imobiliaria_id === imob.id
return (
@ -741,12 +741,15 @@ export default function FilterSidebar({
key={imob.id}
onClick={() => set({ imobiliaria_id: isActive ? undefined : imob.id })}
aria-pressed={isActive}
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
? 'bg-brand/15 border-brand/40 text-brand font-medium'
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
}`}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
? 'bg-brand/10 text-brand font-medium'
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
}`}
>
{imob.name}
<span>{imob.name}</span>
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
)}
</button>
)
})}
@ -762,9 +765,9 @@ export default function FilterSidebar({
open={openSections['localizacao']}
onToggle={() => toggleSection('localizacao')}
>
{/* City — chips when ≤ 5, select when more */}
{/* City — list full-width */}
{cities.length <= 5 ? (
<div className="flex flex-wrap gap-1.5 mb-3">
<div className="flex flex-col gap-0.5 mb-3">
{cities.map(city => {
const isActive = filters.city_id === city.id
return (
@ -772,13 +775,15 @@ export default function FilterSidebar({
key={city.id}
onClick={() => set({ city_id: isActive ? undefined : city.id, neighborhood_ids: undefined })}
aria-pressed={isActive}
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
? 'bg-brand/15 border-brand/40 text-brand font-medium'
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
}`}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
? 'bg-brand/10 text-brand font-medium'
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
}`}
>
{city.name}
<span className="ml-1 opacity-50">{city.state}</span>
<span>{city.name}<span className="ml-1 opacity-50">{city.state}</span></span>
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
)}
</button>
)
})}
@ -817,19 +822,22 @@ export default function FilterSidebar({
renderItem={(nbh, isPopular) => {
const isActive = (filters.neighborhood_ids ?? []).includes(nbh.id)
return (
<span className="inline-block mb-1.5 mr-1.5">
<button
onClick={() => toggleNeighborhood(nbh.id)}
aria-pressed={isActive}
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
? 'bg-brand/15 border-brand/40 text-brand font-medium'
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
<button
onClick={() => toggleNeighborhood(nbh.id)}
aria-pressed={isActive}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
? 'bg-brand/10 text-brand font-medium'
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
}`}
>
>
<span className="flex items-center gap-1.5">
{nbh.name}
{isPopular && <PopularBadge />}
</button>
</span>
</span>
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
)}
</button>
)
}}
/>
@ -879,19 +887,22 @@ export default function FilterSidebar({
renderItem={(sub, isPopular) => {
const isActive = (filters.subtype_ids ?? []).includes(sub.id)
return (
<span className="inline-block mb-1.5 mr-1.5">
<button
onClick={() => toggleSubtype(sub.id)}
aria-pressed={isActive}
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
? 'bg-brand/15 border-brand/40 text-brand font-medium'
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
<button
onClick={() => toggleSubtype(sub.id)}
aria-pressed={isActive}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
? 'bg-brand/10 text-brand font-medium'
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
}`}
>
>
<span className="flex items-center gap-1.5">
{sub.name}
{isPopular && <PopularBadge />}
</button>
</span>
</span>
{isActive && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
)}
</button>
)
}}
/>
@ -919,7 +930,7 @@ export default function FilterSidebar({
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-all duration-150 ${filters.include_condo
? 'bg-brand border-brand'
: 'bg-transparent border-borderStandard group-hover/condo:border-brand/50'
}`}
}`}
aria-hidden="true"
>
{filters.include_condo && (

View file

@ -1,74 +1,148 @@
const footerLinks = [
{ label: 'Imóveis', href: '/imoveis' },
{ label: 'Sobre', href: '/sobre' },
{ label: 'Contato', href: '#contato' },
{ label: 'Política de Privacidade', href: '/politica-de-privacidade' },
]
import { Link } from 'react-router-dom'
const currentYear = new Date().getFullYear()
function FooterColumn({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-3">
<h3 className="text-[10px] font-semibold tracking-widest uppercase text-textQuaternary">
{title}
</h3>
<ul className="flex flex-col gap-2 list-none m-0 p-0">
{children}
</ul>
</div>
)
}
function FooterLink({ to, href, children }: { to?: string; href?: string; children: React.ReactNode }) {
const cls = 'text-sm text-textTertiary hover:text-textSecondary transition-colors duration-150'
if (to) return <li><Link to={to} className={cls}>{children}</Link></li>
return <li><a href={href} className={cls}>{children}</a></li>
}
function InstagramIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2" y="2" width="20" height="20" rx="5" /><circle cx="12" cy="12" r="4" /><circle cx="17.5" cy="6.5" r="0.5" fill="currentColor" stroke="none" />
</svg>
)
}
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
</svg>
)
}
function WhatsAppIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
)
}
export default function Footer() {
return (
<footer
role="contentinfo"
className="bg-panel border-t border-borderSubtle py-10 px-6"
className="bg-panel border-t border-borderSubtle"
>
<div className="max-w-[1200px] mx-auto">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="w-5 h-5 bg-brand-indigo rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{/* Main grid */}
<div className="max-w-[1200px] mx-auto px-6 py-12">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-10">
{/* Brand — ocupa 2 colunas no lg */}
<div className="lg:col-span-2 flex flex-col gap-4">
<Link
to="/"
className="flex items-center gap-2 w-fit"
aria-label="ImobiliáriaHub — Página inicial"
>
<span className="w-6 h-6 bg-brand-indigo rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
I
</span>
<span className="text-textPrimary font-semibold text-sm">
ImobiliáriaHub
</span>
</div>
<p className="text-xs text-textTertiary max-w-[260px] leading-relaxed">
Conectando pessoas aos melhores imóveis da região desde 2014.
</Link>
<p className="text-sm text-textTertiary max-w-[280px] leading-relaxed">
Conectamos você ao imóvel ideal com segurança, transparência e agilidade.
</p>
{/* Redes sociais */}
<div className="flex items-center gap-3 mt-1">
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
className="text-textQuaternary hover:text-textSecondary transition-colors duration-150"
>
<InstagramIcon />
</a>
<a
href="https://facebook.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
className="text-textQuaternary hover:text-textSecondary transition-colors duration-150"
>
<FacebookIcon />
</a>
<a
href="https://wa.me/5511999999999"
target="_blank"
rel="noopener noreferrer"
aria-label="WhatsApp"
className="text-textQuaternary hover:text-textSecondary transition-colors duration-150"
>
<WhatsAppIcon />
</a>
</div>
</div>
{/* Nav links */}
<nav aria-label="Rodapé — navegação">
<ul className="flex flex-wrap gap-x-6 gap-y-2 list-none m-0 p-0">
{footerLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="text-xs text-textTertiary hover:text-textSecondary transition-colors duration-150"
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
{/* Institucional */}
<FooterColumn title="A Imobiliária">
<FooterLink to="/sobre">Quem somos</FooterLink>
<FooterLink to="/corretores">Equipe</FooterLink>
<FooterLink to="/trabalhe-conosco">Trabalhe Conosco</FooterLink>
<FooterLink to="/contato">Fale conosco</FooterLink>
<FooterLink to="/politica-de-privacidade">Política de Privacidade</FooterLink>
</FooterColumn>
{/* Imóveis */}
<FooterColumn title="Imóveis">
<FooterLink to="/imoveis?listing_type=venda">Imóveis para comprar</FooterLink>
<FooterLink to="/imoveis?listing_type=aluguel">Imóveis para alugar</FooterLink>
<FooterLink to="/cadastro-residencia">Anunciar seu imóvel</FooterLink>
<FooterLink to="/favoritos">Favoritos</FooterLink>
</FooterColumn>
{/* Atendimento */}
<FooterColumn title="Atendimento">
<FooterLink href="tel:+5511999999999">(11) 99999-9999</FooterLink>
<FooterLink href="mailto:contato@imobiliariahub.com.br">contato@imobiliariahub.com.br</FooterLink>
<li className="text-sm text-textTertiary leading-relaxed">
Rua Exemplo, 1000 Centro<br />CEP: 01310-100
</li>
</FooterColumn>
{/* Contact */}
<div className="flex flex-col gap-1.5">
<a
href="tel:+5511999999999"
className="text-xs text-textTertiary hover:text-textSecondary transition-colors duration-150"
aria-label="Telefone: (11) 99999-9999"
>
(11) 99999-9999
</a>
<a
href="mailto:contato@imobiliariahub.com.br"
className="text-xs text-textTertiary hover:text-textSecondary transition-colors duration-150"
aria-label="E-mail: contato@imobiliariahub.com.br"
>
contato@imobiliariahub.com.br
</a>
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-borderSubtle">
<p className="text-xs text-textQuaternary text-center">
{/* Bottom bar */}
<div className="border-t border-borderSubtle">
<div className="max-w-[1200px] mx-auto px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-2">
<p className="text-xs text-textQuaternary">
© {currentYear} ImobiliáriaHub. Todos os direitos reservados.
</p>
<Link
to="/politica-de-privacidade"
className="text-xs text-textQuaternary hover:text-textTertiary transition-colors duration-150"
>
Política de Privacidade
</Link>
</div>
</div>
</footer>

View file

@ -1,26 +1,20 @@
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import type { LocalFavoriteEntry } from '../contexts/FavoritesContext';
import { useFavorites } from '../contexts/FavoritesContext';
interface HeartButtonProps {
propertyId: string;
snapshot?: LocalFavoriteEntry;
className?: string;
}
export default function HeartButton({ propertyId, className = '' }: HeartButtonProps) {
const { isAuthenticated } = useAuth();
export default function HeartButton({ propertyId, snapshot, className = '' }: HeartButtonProps) {
const { favoriteIds, toggle } = useFavorites();
const navigate = useNavigate();
const isFav = favoriteIds.has(propertyId);
async function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated) {
navigate('/login');
return;
}
await toggle(propertyId);
await toggle(propertyId, snapshot);
}
return (
@ -28,8 +22,8 @@ export default function HeartButton({ propertyId, className = '' }: HeartButtonP
onClick={handleClick}
aria-label={isFav ? 'Remover dos favoritos' : 'Adicionar aos favoritos'}
className={`rounded-full p-1.5 transition-colors ${isFav
? 'text-red-400 hover:text-red-300'
: 'text-white/40 hover:text-white/70'
? 'text-red-400 hover:text-red-300'
: 'text-white/40 hover:text-white/70'
} ${className}`}
>
<svg

View file

@ -1,35 +1,19 @@
import { useEffect, useRef, useState } from 'react'
import PropertyRowCard from './PropertyRowCard'
import { getFeaturedProperties } from '../services/properties'
import type React from 'react'
import type { Property } from '../types/property'
import PropertyRowCard from './PropertyRowCard'
import { useTheme } from '../contexts/ThemeContext'
import { useInView } from '../hooks/useInView'
// ── Card com animação de entrada ao rolar ─────────────────────────────────────
function RiseCard({ children, index }: { children: React.ReactNode; index: number }) {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true)
observer.disconnect()
}
},
{ threshold: 0.05 }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
const { ref, inView } = useInView({ threshold: 0.05 })
return (
<div
ref={ref}
style={{ transitionDelay: `${Math.min(index * 60, 240)}ms` }}
className={`transition-all duration-700 ease-out ${visible
className={`transition-all duration-700 ease-out ${inView
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-12'
}`}
@ -62,15 +46,17 @@ function RowSkeleton() {
// ── Scroll hint (seta animada) ────────────────────────────────────────────────
function ScrollHint({ label }: { label: string }) {
function ScrollHint({ label, isLight }: { label: string; isLight?: boolean }) {
return (
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 select-none pointer-events-none">
<span className="text-white/40 text-[11px] tracking-[0.2em] uppercase font-medium">{label}</span>
<span className={`text-[11px] tracking-[0.2em] uppercase font-medium ${
isLight ? 'text-[#3a3f6e]/50' : 'text-white/40'
}`}>{label}</span>
<div className="flex flex-col items-center gap-0.5 opacity-40">
{[0, 1, 2].map((i) => (
<svg
key={i}
className="w-3.5 h-3.5 text-white"
className={`w-3.5 h-3.5 ${isLight ? 'text-[#3a3f6e]' : 'text-white'}`}
style={{ animation: `fadeDown 1.4s ease-in-out ${i * 0.2}s infinite` }}
fill="none"
viewBox="0 0 24 24"
@ -93,6 +79,8 @@ interface HomeScrollSceneProps {
ctaUrl: string
backgroundImage?: string | null
isLoading?: boolean
properties: Property[]
loadingProperties: boolean
}
export default function HomeScrollScene({
@ -102,27 +90,14 @@ export default function HomeScrollScene({
ctaUrl,
backgroundImage,
isLoading = false,
properties,
loadingProperties,
}: HomeScrollSceneProps) {
const [properties, setProperties] = useState<Property[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
getFeaturedProperties()
.then(setProperties)
.catch(() => { })
.finally(() => setLoading(false))
}, [])
const { resolvedTheme } = useTheme()
const isLight = resolvedTheme === 'light'
return (
<>
{/* Keyframes inline para as setas e fade */}
<style>{`
@keyframes fadeDown {
0%, 100% { opacity: 0; transform: translateY(-4px); }
50% { opacity: 1; transform: translateY(4px); }
}
`}</style>
<div className="relative">
{/* ── Imagem de fundo sticky ───────────────────────────────────── */}
<div className="sticky top-0 h-screen z-0 overflow-hidden">
@ -131,18 +106,28 @@ export default function HomeScrollScene({
src={backgroundImage}
alt=""
aria-hidden="true"
fetchPriority="high"
loading="eager"
decoding="async"
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0"
style={{
background: [
'radial-gradient(ellipse 90% 70% at 20% 45%, rgba(94,106,210,0.22) 0%, transparent 60%)',
'radial-gradient(ellipse 60% 50% at 80% 25%, rgba(94,106,210,0.10) 0%, transparent 55%)',
'radial-gradient(ellipse 80% 60% at 50% 90%, rgba(10,8,20,0.8) 0%, transparent 70%)',
'linear-gradient(135deg, #0d0e14 0%, #08090a 55%, #0b0c10 100%)',
].join(','),
background: isLight
? [
'radial-gradient(ellipse 90% 70% at 20% 45%, rgba(94,106,210,0.28) 0%, transparent 60%)',
'radial-gradient(ellipse 60% 50% at 80% 25%, rgba(94,106,210,0.16) 0%, transparent 55%)',
'radial-gradient(ellipse 80% 60% at 50% 90%, rgba(180,190,255,0.55) 0%, transparent 70%)',
'linear-gradient(135deg, #dde0f7 0%, #eaedff 55%, #e2e5f8 100%)',
].join(',')
: [
'radial-gradient(ellipse 90% 70% at 20% 45%, rgba(94,106,210,0.22) 0%, transparent 60%)',
'radial-gradient(ellipse 60% 50% at 80% 25%, rgba(94,106,210,0.10) 0%, transparent 55%)',
'radial-gradient(ellipse 80% 60% at 50% 90%, rgba(10,8,20,0.8) 0%, transparent 70%)',
'linear-gradient(135deg, #0d0e14 0%, #08090a 55%, #0b0c10 100%)',
].join(','),
}}
/>
)}
@ -151,8 +136,9 @@ export default function HomeScrollScene({
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
'linear-gradient(to bottom, rgba(8,9,10,0.25) 0%, rgba(8,9,10,0) 30%, rgba(8,9,10,0.5) 80%, rgba(8,9,10,0.95) 100%)',
background: isLight
? 'linear-gradient(to bottom, rgba(216,220,255,0.2) 0%, rgba(216,220,255,0) 30%, rgba(210,215,248,0.75) 80%, rgba(205,210,245,0.98) 100%)'
: 'linear-gradient(to bottom, rgba(8,9,10,0.25) 0%, rgba(8,9,10,0) 30%, rgba(8,9,10,0.5) 80%, rgba(8,9,10,0.95) 100%)',
}}
/>
@ -160,20 +146,28 @@ export default function HomeScrollScene({
<div className="absolute inset-0 flex items-center justify-center z-10 px-6 pb-24">
{isLoading ? (
<div className="text-center max-w-[720px] w-full space-y-4 animate-pulse">
<div className="h-12 bg-white/10 rounded-xl w-4/5 mx-auto" />
<div className="h-6 bg-white/10 rounded-xl w-3/5 mx-auto" />
<div className="h-11 bg-white/10 rounded-full w-36 mx-auto mt-6" />
<div className={`h-12 rounded-xl w-4/5 mx-auto ${isLight ? 'bg-indigo-200/50' : 'bg-white/10'}`} />
<div className={`h-6 rounded-xl w-3/5 mx-auto ${isLight ? 'bg-indigo-200/50' : 'bg-white/10'}`} />
<div className={`h-11 rounded-full w-36 mx-auto mt-6 ${isLight ? 'bg-indigo-200/50' : 'bg-white/10'}`} />
</div>
) : (
<div className="text-center max-w-[720px] w-full">
<h1
className="text-[36px] md:text-[52px] lg:text-[68px] font-semibold leading-[1.08] tracking-tight text-white drop-shadow-[0_2px_24px_rgba(0,0,0,0.6)]"
className={`text-[36px] md:text-[52px] lg:text-[68px] font-semibold leading-[1.08] tracking-tight ${
isLight
? 'text-[#1a1d3a] drop-shadow-[0_1px_12px_rgba(94,106,210,0.18)]'
: 'text-white drop-shadow-[0_2px_24px_rgba(0,0,0,0.6)]'
}`}
style={{ fontFeatureSettings: '"cv01","ss03"' }}
>
{headline}
</h1>
{subheadline && (
<p className="mt-4 text-base md:text-lg text-white/75 max-w-[560px] mx-auto leading-relaxed drop-shadow-[0_1px_8px_rgba(0,0,0,0.5)]">
<p className={`mt-4 text-base md:text-lg max-w-[560px] mx-auto leading-relaxed ${
isLight
? 'text-[#3a3f6e]/80 drop-shadow-none'
: 'text-white/75 drop-shadow-[0_1px_8px_rgba(0,0,0,0.5)]'
}`}>
{subheadline}
</p>
)}
@ -191,7 +185,7 @@ export default function HomeScrollScene({
</div>
{/* Indicador de rolar */}
<ScrollHint label="Imóveis em destaque" />
<ScrollHint label="Imóveis em destaque" isLight={isLight} />
</div>
{/* ── Seção de imóveis que sobe sobre a imagem ─────────────────── */}
@ -200,30 +194,36 @@ export default function HomeScrollScene({
<div
className="h-48 pointer-events-none"
style={{
background: 'linear-gradient(to bottom, transparent 0%, #08090a 100%)',
background: isLight
? 'linear-gradient(to bottom, transparent 0%, #dde0f7 100%)'
: 'linear-gradient(to bottom, transparent 0%, #08090a 100%)',
}}
/>
<div
className="pb-40"
style={{ background: '#08090a' }}
style={{ background: isLight ? '#dde0f7' : '#08090a' }}
>
{/* Cabeçalho da seção */}
<div className="max-w-[980px] mx-auto px-6 pb-8">
<h2
className="text-2xl md:text-3xl font-medium text-textPrimary tracking-tight"
className={`text-2xl md:text-3xl font-medium tracking-tight ${
isLight ? 'text-[#1a1d3a]' : 'text-textPrimary'
}`}
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
>
Imóveis em Destaque
</h2>
<p className="mt-1.5 text-textSecondary text-sm">
<p className={`mt-1.5 text-sm ${
isLight ? 'text-[#3a3f6e]/70' : 'text-textSecondary'
}`}>
Selecionados especialmente para você
</p>
</div>
{/* Cards */}
<div className="max-w-[980px] mx-auto px-6 flex flex-col gap-4">
{loading
{loadingProperties
? Array.from({ length: 3 }).map((_, i) => <RowSkeleton key={i} />)
: properties.map((p, i) => (
<RiseCard key={p.id} index={i}>
@ -234,7 +234,7 @@ export default function HomeScrollScene({
</div>
{/* CTA direto para /imoveis */}
{!loading && (
{!loadingProperties && (
<div className="max-w-[980px] mx-auto px-6 mt-16 flex flex-col items-center gap-4">
<a
href="/imoveis"

View file

@ -1,17 +1,35 @@
import { useEffect, useRef, useState } from 'react'
import { Link, NavLink } from 'react-router-dom'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useFavorites } from '../contexts/FavoritesContext'
import { ThemeToggle } from './ThemeToggle'
const navLinks = [
{ label: 'Imóveis', href: '/imoveis', internal: true },
{ label: 'Corretores', href: '/corretores', internal: true },
{ label: 'Sobre', href: '#sobre', internal: false },
{ label: 'Contato', href: '#contato', internal: false },
// ─── Types ────────────────────────────────────────────────────────────────────
type Overlay = 'closed' | 'mobile' | 'user' | 'admin'
// ─── Navigation config ────────────────────────────────────────────────────────
interface NavLinkDef {
label: string
href: string
/** pathname prefix/exact for active matching when href has query params */
matchPath?: string
matchQuery?: Record<string, string>
}
const publicNavLinks: NavLinkDef[] = [
{ label: 'Início', href: '/' },
{ label: 'Comprar', href: '/imoveis?listing_type=venda', matchPath: '/imoveis', matchQuery: { listing_type: 'venda' } },
{ label: 'Alugar', href: '/imoveis?listing_type=aluguel', matchPath: '/imoveis', matchQuery: { listing_type: 'aluguel' } },
{ label: 'Equipe', href: '/corretores' },
{ label: 'Sobre', href: '/sobre' },
{ label: 'Contato', href: '/contato' },
]
const adminNavItems = [
interface AdminNavItem { to: string; label: string }
const adminNavItems: AdminNavItem[] = [
{ to: '/admin/properties', label: 'Imóveis' },
{ to: '/admin/corretores', label: 'Corretores' },
{ to: '/admin/clientes', label: 'Clientes' },
@ -21,51 +39,177 @@ const adminNavItems = [
{ to: '/admin/cidades', label: 'Cidades' },
{ to: '/admin/amenidades', label: 'Amenidades' },
{ to: '/admin/analytics', label: 'Analytics' },
{ to: '/admin/leads', label: 'Leads' },
{ to: '/admin/candidaturas', label: 'Candidaturas' },
{ to: '/admin/homepage-config', label: 'Conf. Home' },
{ to: '/admin/contato-config', label: 'Conf. Contato' },
]
const clientNavItems = [
{ to: '/area-do-cliente', label: 'Painel', end: true },
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false },
interface ClientNavItem { to: string; label: string; end: boolean }
const clientNavItems: ClientNavItem[] = [
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false },
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false },
{ to: '/area-do-cliente/boletos', label: 'Boletos', end: false },
{ to: '/area-do-cliente/conta', label: 'Minha conta', end: false },
]
const dropdownItemCls = ({ isActive }: { isActive: boolean }) =>
`block px-4 py-2 text-sm transition-colors ${isActive
? 'bg-surface text-textPrimary font-medium'
: 'text-textSecondary hover:text-textPrimary hover:bg-surface'
}`
const adminUserMenuItems: ClientNavItem[] = [
{ to: '/admin/properties', label: 'Painel admin', end: false },
]
const adminDropdownItemCls = ({ isActive }: { isActive: boolean }) =>
`block px-4 py-2 text-sm transition-colors ${isActive
? 'bg-admin/10 text-admin font-semibold'
: 'text-admin/70 hover:text-admin hover:bg-admin/[0.06]'
}`
// ─── Helper: active state for query-param routes ──────────────────────────────
function useQueryNavActive(link: NavLinkDef): boolean {
const location = useLocation()
if (!link.matchPath) return false
if (location.pathname !== link.matchPath) return false
if (!link.matchQuery) return true
const params = new URLSearchParams(location.search)
return Object.entries(link.matchQuery).every(([k, v]) => params.get(k) === v)
}
// ─── Subcomponents ────────────────────────────────────────────────────────────
function PublicNavItem({ link, onClick }: { link: NavLinkDef; onClick?: () => void }) {
const isActive = useQueryNavActive(link)
if (link.matchPath) {
return (
<a
href={link.href}
onClick={(e) => {
e.preventDefault()
window.history.pushState({}, '', link.href)
window.dispatchEvent(new PopStateEvent('popstate'))
onClick?.()
}}
className={`navbar-link ${isActive ? 'navbar-link--active' : ''}`}
>
{link.label}
</a>
)
}
return (
<NavLink
to={link.href}
className={({ isActive: a }) => `navbar-link ${a ? 'navbar-link--active' : ''}`}
onClick={onClick}
>
{link.label}
</NavLink>
)
}
function MobilePublicNavItem({ link, onClick }: { link: NavLinkDef; onClick: () => void }) {
const isActive = useQueryNavActive(link)
if (link.matchPath) {
return (
<a
href={link.href}
onClick={(e) => {
e.preventDefault()
window.history.pushState({}, '', link.href)
window.dispatchEvent(new PopStateEvent('popstate'))
onClick()
}}
className={`navbar-mobile-link ${isActive ? 'navbar-mobile-link--active' : ''}`}
>
{link.label}
</a>
)
}
return (
<NavLink
to={link.href}
className={({ isActive: a }) => `navbar-mobile-link ${a ? 'navbar-mobile-link--active' : ''}`}
onClick={onClick}
>
{link.label}
</NavLink>
)
}
function FavoritesNavLink({ href }: { href: string }) {
const { favoriteIds } = useFavorites()
const count = favoriteIds.size
return (
<Link
to={href}
className="relative flex items-center gap-1 text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium"
aria-label={count > 0 ? `Favoritos — ${count} imóvel${count > 1 ? 'is' : ''} salvo${count > 1 ? 's' : ''}` : 'Favoritos'}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
Favoritos
{count > 0 && (
<span aria-hidden="true" className="ml-0.5 min-w-[16px] h-4 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center px-1 leading-none">
{count > 99 ? '99+' : count}
</span>
)}
</Link>
)
}
// ─── Navbar ───────────────────────────────────────────────────────────────────
export default function Navbar() {
const [menuOpen, setMenuOpen] = useState(false)
const [adminOpen, setAdminOpen] = useState(false)
const [clientOpen, setClientOpen] = useState(false)
// Single overlay controller — FR-011, FR-012, FR-013
const [overlay, setOverlay] = useState<Overlay>('closed')
const { user, isAuthenticated, isLoading, logout } = useAuth()
const location = useLocation()
const isAdmin = isAuthenticated && user && user.role === 'admin'
// Derived variant
const isAdmin = isAuthenticated && user?.role === 'admin'
const isClient = isAuthenticated && !isAdmin
// Refs for click-outside detection
const adminRef = useRef<HTMLDivElement>(null)
const clientRef = useRef<HTMLDivElement>(null)
const userRef = useRef<HTMLDivElement>(null)
// Convenience toggles
const open = useCallback((ctx: Overlay) => setOverlay(prev => prev === ctx ? 'closed' : ctx), [])
const close = useCallback(() => setOverlay('closed'), [])
// Close on route change — FR-013
useEffect(() => { close() }, [location.pathname, location.search, close])
// Close on click outside
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (adminRef.current && !adminRef.current.contains(e.target as Node)) {
setAdminOpen(false)
if (overlay === 'closed') return
if (overlay === 'mobile') return // mobile closes via button only
if (overlay === 'admin' && adminRef.current && !adminRef.current.contains(e.target as Node)) {
close()
}
if (clientRef.current && !clientRef.current.contains(e.target as Node)) {
setClientOpen(false)
if (overlay === 'user' && userRef.current && !userRef.current.contains(e.target as Node)) {
close()
}
}
document.addEventListener('mousedown', handleOutside)
return () => document.removeEventListener('mousedown', handleOutside)
}, [])
}, [overlay, close])
// Close on Escape key — FR-014
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && overlay !== 'closed') close()
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [overlay, close])
// Logout and close any open overlay
const handleLogout = useCallback(() => {
close()
logout()
}, [close, logout])
const menuOpen = overlay === 'mobile'
const adminOpen = overlay === 'admin'
const userOpen = overlay === 'user'
const firstName = user?.name.split(' ')[0] ?? ''
const userMenuItems = isAdmin ? adminUserMenuItems : clientNavItems
const favoritesHref = isAuthenticated ? (isAdmin ? '/admin/favoritos' : '/area-do-cliente/favoritos') : '/favoritos'
return (
<header
@ -75,178 +219,224 @@ export default function Navbar() {
>
<nav
aria-label="Navegação principal"
className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between"
className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between gap-4"
>
{/* Logo */}
<Link
to="/"
className="flex items-center gap-2 text-textPrimary font-semibold text-base tracking-tight hover:opacity-80 transition-opacity"
className="flex items-center gap-2 text-textPrimary font-semibold text-base tracking-tight hover:opacity-80 transition-opacity shrink-0"
aria-label="ImobiliáriaHub — Página inicial"
>
<span className="w-6 h-6 bg-brand-indigo rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
I
</span>
<span>ImobiliáriaHub</span>
<span className="hidden sm:inline">ImobiliáriaHub</span>
</Link>
{/* Desktop nav */}
<ul className="hidden md:flex items-center gap-6 list-none m-0 p-0">
{navLinks.map((link) => (
{/* Desktop nav — public links */}
<ul className="hidden md:flex items-center gap-5 list-none m-0 p-0 flex-1 justify-center">
{publicNavLinks.map((link) => (
<li key={link.href}>
{link.internal ? (
<Link to={link.href} className="text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium">
{link.label}
</Link>
) : (
<a href={link.href} className="text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium">
{link.label}
</a>
)}
<PublicNavItem link={link} />
</li>
))}
{/* Admin dropdown */}
{isAdmin && (
<li>
<div ref={adminRef} className="relative">
<button
onClick={() => { setAdminOpen(o => !o); setClientOpen(false) }}
className="flex items-center gap-1 text-sm text-admin hover:text-admin/80 font-semibold transition-colors"
>
Admin
<svg className={`w-3.5 h-3.5 transition-transform ${adminOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{adminOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50">
{adminNavItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={adminDropdownItemCls}
onClick={() => setAdminOpen(false)}
>
{item.label}
</NavLink>
))}
</div>
)}
</div>
</li>
)}
{/* Client dropdown */}
{isAuthenticated && user && !isAdmin && (
<li>
<div ref={clientRef} className="relative">
<button
onClick={() => { setClientOpen(o => !o); setAdminOpen(false) }}
className="flex items-center gap-1.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#5e6ad2] text-xs font-semibold text-white flex-shrink-0">
{user.name.charAt(0).toUpperCase()}
</span>
<span className="max-w-[80px] truncate">{user.name.split(' ')[0]}</span>
<svg className={`w-3.5 h-3.5 transition-transform ${clientOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{clientOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50">
{clientNavItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={dropdownItemCls}
onClick={() => setClientOpen(false)}
>
{item.label}
</NavLink>
))}
<div className="my-1 border-t border-borderSubtle" />
<button
onClick={() => { setClientOpen(false); logout() }}
className="block w-full text-left px-4 py-2 text-sm text-textTertiary hover:text-textPrimary hover:bg-surface transition-colors"
>
Sair
</button>
</div>
)}
</div>
</li>
)}
<li><ThemeToggle /></li>
<li>
<FavoritesNavLink href={favoritesHref} />
</li>
</ul>
{/* Desktop auth (apenas não-autenticado) */}
<div className="hidden md:flex items-center gap-3">
{/* Desktop: contextual actions + theme + CTA */}
<div className="hidden md:flex items-center gap-2 shrink-0">
{/* Admin dropdown */}
{isAdmin && (
<div ref={adminRef} className="relative">
<button
id="admin-menu-btn"
aria-haspopup="true"
aria-expanded={adminOpen}
aria-controls="admin-dropdown"
onClick={() => open('admin')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open('admin') } }}
className="navbar-trigger navbar-trigger--admin"
>
Admin
<svg className={`w-3.5 h-3.5 transition-transform duration-150 ${adminOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{adminOpen && (
<div
id="admin-dropdown"
role="menu"
aria-labelledby="admin-menu-btn"
className="absolute right-0 top-full mt-2 w-52 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50"
>
{adminNavItems.map(item => (
<NavLink
key={item.to}
to={item.to}
role="menuitem"
className={({ isActive }) =>
`navbar-dropdown-item ${isActive ? 'navbar-dropdown-item--admin-active' : 'navbar-dropdown-item--admin'}`
}
onClick={close}
>
{item.label}
</NavLink>
))}
</div>
)}
</div>
)}
<ThemeToggle />
<Link
to="/cadastro-residencia"
className="navbar-cta"
>
Anunciar imóvel
</Link>
{isLoading ? (
<div className="h-8 w-16 animate-pulse rounded-lg bg-white/[0.06]" />
<div aria-hidden="true" className="h-8 w-16 animate-pulse rounded-lg bg-white/[0.06]" />
) : !isAuthenticated ? (
<Link
to="/login"
className="rounded-lg bg-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-white transition hover:bg-[#6872d8]"
className="navbar-cta--primary"
>
Entrar
</Link>
) : isAdmin ? (
/* Admin: logout simples ao lado do dropdown */
<button
onClick={logout}
className="text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium"
>
Sair
</button>
) : null}
{/* User dropdown (sempre no fim da barra para usuários logados) */}
{isAuthenticated && user && (
<div ref={userRef} className="relative">
<button
id="user-menu-btn"
aria-haspopup="true"
aria-expanded={userOpen}
aria-controls="user-dropdown"
aria-label={`Menu da conta de ${firstName}`}
onClick={() => open('user')}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open('user') } }}
className="navbar-trigger"
>
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-[#5e6ad2] text-xs font-semibold text-white flex-shrink-0">
{user.name.charAt(0).toUpperCase()}
</span>
<span className="navbar-username">{firstName}</span>
<svg className={`w-3.5 h-3.5 transition-transform duration-150 ${userOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{userOpen && (
<div
id="user-dropdown"
role="menu"
aria-labelledby="user-menu-btn"
className="absolute right-0 top-full mt-2 w-52 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50"
>
{userMenuItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
role="menuitem"
className={({ isActive }) =>
`navbar-dropdown-item ${isActive ? 'navbar-dropdown-item--active' : ''}`
}
onClick={close}
>
{item.label}
</NavLink>
))}
<div role="separator" className="my-1 border-t border-borderSubtle" />
<button
role="menuitem"
onClick={handleLogout}
className="navbar-dropdown-item navbar-dropdown-item--logout w-full text-left"
>
Sair
</button>
</div>
)}
</div>
)}
</div>
{/* Mobile hamburger */}
<div className="md:hidden flex items-center gap-2">
{/* Mobile: theme + hamburger */}
<div className="md:hidden flex items-center gap-1 shrink-0">
<ThemeToggle />
<button
className="flex flex-col gap-1.5 p-2 rounded text-textSecondary hover:text-textPrimary transition-colors"
aria-label={menuOpen ? 'Fechar menu' : 'Abrir menu'}
className="navbar-hamburger"
aria-label={menuOpen ? 'Fechar menu de navegação' : 'Abrir menu de navegação'}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen(prev => !prev)}
onClick={() => open('mobile')}
>
<span className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? 'translate-y-2 rotate-45' : ''}`} />
<span className={`block w-5 h-0.5 bg-current transition-opacity duration-200 ${menuOpen ? 'opacity-0' : ''}`} />
<span className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? '-translate-y-2 -rotate-45' : ''}`} />
<span aria-hidden="true" className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? 'translate-y-2 rotate-45' : ''}`} />
<span aria-hidden="true" className={`block w-5 h-0.5 bg-current transition-opacity duration-200 ${menuOpen ? 'opacity-0' : ''}`} />
<span aria-hidden="true" className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? '-translate-y-2 -rotate-45' : ''}`} />
</button>
</div>
</nav>
{/* Mobile menu */}
{/* Mobile menu panel */}
{menuOpen && (
<div id="mobile-menu" className="md:hidden border-t border-borderSubtle bg-panel">
<ul className="max-w-[1200px] mx-auto px-6 py-4 flex flex-col gap-1 list-none m-0 p-0">
{navLinks.map((link) => (
<div
id="mobile-menu"
role="dialog"
aria-modal="false"
aria-label="Menu de navegação"
className="md:hidden border-t border-borderSubtle bg-panel"
>
<ul className="max-w-[1200px] mx-auto px-6 py-4 flex flex-col gap-0.5 list-none m-0 p-0">
{/* Public links */}
{publicNavLinks.map((link) => (
<li key={link.href}>
{link.internal ? (
<Link to={link.href} className="block py-2.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium" onClick={() => setMenuOpen(false)}>
{link.label}
</Link>
) : (
<a href={link.href} className="block py-2.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium" onClick={() => setMenuOpen(false)}>
{link.label}
</a>
)}
<MobilePublicNavItem link={link} onClick={close} />
</li>
))}
{/* Mobile admin items */}
<li>
<Link
to={favoritesHref}
className="navbar-mobile-link"
onClick={close}
>
Favoritos
</Link>
</li>
{/* CTA mobile */}
<li className="pt-1">
<Link
to="/cadastro-residencia"
className="block py-2.5 min-h-[44px] flex items-center text-sm font-semibold text-[#5e6ad2] hover:text-[#7170ff] transition-colors"
onClick={close}
>
Anunciar imóvel
</Link>
</li>
{/* Admin section */}
{isAdmin && (
<>
<li className="pt-2 pb-1">
<li className="pt-3 pb-1">
<span className="text-[10px] font-semibold text-textQuaternary uppercase tracking-widest px-0.5">Admin</span>
</li>
{adminNavItems.map(item => (
<li key={item.to}>
<NavLink to={item.to} className={({ isActive }) => `block py-2 text-sm font-medium transition-colors ${isActive ? 'text-admin' : 'text-admin/60 hover:text-admin'}`} onClick={() => setMenuOpen(false)}>
<NavLink
to={item.to}
className={({ isActive }) =>
`navbar-mobile-link ${isActive ? 'text-admin font-medium' : 'text-admin/60 hover:text-admin'}`
}
onClick={close}
>
{item.label}
</NavLink>
</li>
@ -254,37 +444,71 @@ export default function Navbar() {
</>
)}
{/* Mobile client items */}
{isAuthenticated && user && !isAdmin && (
{/* Client section */}
{isClient && user && (
<>
<li className="pt-2 pb-1">
<li className="pt-3 pb-1">
<span className="text-[10px] font-semibold text-textQuaternary uppercase tracking-widest px-0.5">Minha Conta</span>
</li>
{clientNavItems.map(item => (
<li key={item.to}>
<NavLink to={item.to} end={item.end} className={({ isActive }) => `block py-2 text-sm font-medium transition-colors ${isActive ? 'text-textPrimary' : 'text-textSecondary hover:text-textPrimary'}`} onClick={() => setMenuOpen(false)}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`navbar-mobile-link ${isActive ? 'navbar-mobile-link--active' : ''}`
}
onClick={close}
>
{item.label}
</NavLink>
</li>
))}
</>
)}
{/* Mobile auth */}
{!isLoading && (
isAuthenticated ? (
<li>
<button onClick={() => { setMenuOpen(false); logout() }} className="block py-2.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium w-full text-left">
<li className="pt-1 border-t border-borderSubtle mt-2">
<button
onClick={handleLogout}
className="navbar-mobile-link w-full text-left text-textTertiary hover:text-textPrimary"
>
Sair
</button>
</li>
) : (
<li>
<Link to="/login" className="block py-2.5 text-sm font-medium text-[#5e6ad2] hover:text-[#7170ff] transition-colors" onClick={() => setMenuOpen(false)}>
Entrar
</Link>
</>
)}
{/* User section (admin) */}
{isAdmin && user && (
<>
<li className="pt-3 pb-1">
<span className="text-[10px] font-semibold text-textQuaternary uppercase tracking-widest px-0.5">Conta</span>
</li>
)
<li>
<span className="navbar-mobile-link text-textSecondary/90">{user.name}</span>
</li>
</>
)}
{/* Auth actions */}
{!isLoading && !isAuthenticated && (
<li className="pt-1">
<Link
to="/login"
className="block py-2.5 min-h-[44px] flex items-center text-sm font-semibold text-[#5e6ad2] hover:text-[#7170ff] transition-colors"
onClick={close}
>
Entrar
</Link>
</li>
)}
{!isLoading && isAuthenticated && isAdmin && (
<li className="pt-1 border-t border-borderSubtle mt-2">
<button
onClick={handleLogout}
className="navbar-mobile-link w-full text-left text-textTertiary hover:text-textPrimary"
>
Sair
</button>
</li>
)}
</ul>
</div>

View file

@ -2,9 +2,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import HeartButton from '../components/HeartButton';
import ContactModal from './ContactModal';
import { useComparison } from '../contexts/ComparisonContext';
import type { Property } from '../types/property';
import ContactModal from './ContactModal';
interface PropertyCardProps {
property: Property
@ -140,7 +140,10 @@ export default function PropertyCard({ property }: PropertyCardProps) {
loading="lazy"
/>
<div className="absolute top-2 right-2 z-10">
<HeartButton propertyId={property.id} />
<HeartButton
propertyId={property.id}
snapshot={{ id: property.id, slug: property.slug, title: property.title, price: property.price, type: property.type, photo: property.photos?.[0]?.url ?? null, city: property.city?.name ?? null, bedrooms: property.bedrooms, area_m2: property.area_m2 }}
/>
</div>
{/* Badge sobreposto à foto */}
<div className="absolute bottom-2 left-2">

View file

@ -61,19 +61,23 @@ export default function PropertyGridCard({ property }: { property: Property }) {
aria-hidden="true"
/>
{/* Badges */}
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1 pointer-events-none">
{property.is_featured && (
{/* Featured badge */}
{property.is_featured && (
<div className="absolute top-2 left-2 z-10 pointer-events-none">
<span className="inline-flex items-center gap-1 rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm bg-amber-500/90 text-white">
Destaque
</span>
)}
{showNew && (
<span className="inline-flex items-center rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm bg-emerald-500/90 text-white">
</div>
)}
{/* Novo — corner ribbon */}
{showNew && (
<div className="absolute top-0 right-0 w-16 h-16 overflow-hidden z-10 pointer-events-none">
<div className="absolute rotate-45 bg-emerald-500/90 text-white text-[10px] font-bold tracking-wide text-center shadow-sm" style={{ width: '80px', top: '10px', right: '-14px' }}>
Novo
</span>
)}
</div>
</div>
</div>
)}
{/* Listing type */}
<div className="absolute top-2 right-2 z-10 pointer-events-none">

View file

@ -1,8 +1,8 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import ContactModal from './ContactModal'
import { useComparison } from '../contexts/ComparisonContext'
import type { Property } from '../types/property'
import ContactModal from './ContactModal'
import HeartButton from './HeartButton'
// ── Badge helpers ─────────────────────────────────────────────────────────────
@ -79,6 +79,8 @@ function SlideImage({ src, alt }: { src: string; alt: string }) {
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
onLoad={() => setLoaded(true)}
className={`w-full h-full object-cover transition-opacity duration-500 ${loaded ? 'opacity-100' : 'opacity-0'}`}
draggable={false}
@ -126,18 +128,22 @@ function PhotoCarousel({ photos, title, isNew: showNew, isFeatured }: {
))}
{/* Status badges */}
<div className="absolute top-2 left-2 z-20 flex flex-col gap-1 pointer-events-none">
{isFeatured && (
{isFeatured && (
<div className="absolute top-2 left-2 z-20 pointer-events-none">
<span className="inline-flex items-center gap-1 rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm shadow bg-amber-500/90 text-white">
Destaque
</span>
)}
{showNew && (
<span className="inline-flex items-center rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm shadow bg-emerald-500/90 text-white">
</div>
)}
{/* Novo — corner ribbon */}
{showNew && (
<div className="absolute top-0 right-0 w-16 h-16 overflow-hidden z-20 pointer-events-none">
<div className="absolute rotate-45 bg-emerald-500/90 text-white text-[10px] font-bold tracking-wide text-center shadow-sm" style={{ width: '80px', top: '8px', right: '-27px' }}>
Novo
</span>
)}
</div>
</div>
</div>
)}
{/* Prev / Next — visible on mobile, hover-only on desktop */}
{slides.length > 1 && (
@ -214,18 +220,12 @@ export default function PropertyRowCard({ property }: { property: Property }) {
</span>
</div>
{/* Subtype badge */}
{property.subtype && (
<div className="absolute bottom-3 left-3 z-20 pointer-events-none">
<span className="inline-flex items-center rounded-full text-[11px] font-medium px-2 py-0.5 backdrop-blur-sm shadow bg-black/50 text-white/90 border border-white/20">
{property.subtype.name}
</span>
</div>
)}
{/* Heart */}
<div className="absolute top-3 right-3 z-20">
<HeartButton propertyId={property.id} />
<HeartButton
propertyId={property.id}
snapshot={{ id: property.id, slug: property.slug, title: property.title, price: property.price, type: property.type, photo: property.photos?.[0]?.url ?? null, city: property.city?.name ?? null, bedrooms: property.bedrooms, area_m2: property.area_m2 }}
/>
</div>
</div>
@ -239,16 +239,23 @@ export default function PropertyRowCard({ property }: { property: Property }) {
{/* ── Info (right) ─────────────────────────────────────────────── */}
<div className="relative z-10 flex flex-col flex-1 min-w-0 p-5 gap-2 cursor-pointer" onClick={() => navigate(`/imoveis/${property.slug}`)}>
{/* Title + code */}
{/* Title + code + subtype */}
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-textPrimary leading-snug line-clamp-2 flex-1">
{property.title}
</h3>
{property.code && (
<span className="text-[11px] text-textTertiary bg-surface border border-borderSubtle rounded px-1.5 py-0.5 shrink-0 font-mono">
#{property.code}
</span>
)}
<div className="flex flex-row items-center gap-1 shrink-0 flex-wrap justify-end">
{property.subtype && (
<span className="text-[11px] text-textTertiary bg-surface border border-borderSubtle rounded px-1.5 py-0.5">
{property.subtype.name}
</span>
)}
{property.code && (
<span className="text-[11px] text-textTertiary bg-surface border border-borderSubtle rounded px-1.5 py-0.5 font-mono">
#{property.code}
</span>
)}
</div>
</div>
@ -270,14 +277,14 @@ export default function PropertyRowCard({ property }: { property: Property }) {
<span className="text-xs text-textTertiary font-normal ml-1">/mês</span>
)}
</p>
{(property.condo_fee || property.iptu_anual) && (
{(property.condo_fee != null || property.iptu_anual != null) && (
<div className="flex items-center gap-3 flex-wrap">
{property.condo_fee && parseFloat(property.condo_fee) > 0 && (
{property.condo_fee != null && (
<span className="text-[11px] text-textTertiary">
Cond. {formatPrice(property.condo_fee)}/mês
</span>
)}
{property.iptu_anual && parseFloat(property.iptu_anual) > 0 && (
{property.iptu_anual != null && (
<span className="text-[11px] text-textTertiary">
IPTU {formatPrice(String(parseFloat(property.iptu_anual) / 12))}/mês
</span>

View file

@ -10,6 +10,7 @@ interface AuthContextValue {
login: (data: LoginCredentials) => Promise<void>
register: (data: RegisterCredentials) => Promise<void>
logout: () => void
updateUser: (partial: Partial<User>) => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
@ -55,6 +56,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.location.href = '/login'
}, [])
const updateUser = useCallback((partial: Partial<User>) => {
setUser(prev => (prev ? { ...prev, ...partial } : prev))
}, [])
return (
<AuthContext.Provider
value={{
@ -65,6 +70,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
register,
logout,
updateUser,
}}
>
{children}

View file

@ -2,9 +2,32 @@ import React, { createContext, useCallback, useContext, useEffect, useState } fr
import { addFavorite, getFavorites, removeFavorite } from '../services/clientArea';
import { useAuth } from './AuthContext';
export interface LocalFavoriteEntry {
id: string;
slug: string;
title: string;
price: string;
type: 'venda' | 'aluguel';
photo: string | null;
city: string | null;
bedrooms: number;
area_m2: number;
}
const LOCAL_KEY = 'local_favorites';
function readLocal(): LocalFavoriteEntry[] {
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '[]'); } catch { return []; }
}
function writeLocal(entries: LocalFavoriteEntry[]) {
localStorage.setItem(LOCAL_KEY, JSON.stringify(entries));
}
interface FavoritesContextValue {
favoriteIds: Set<string>;
toggle: (propertyId: string) => Promise<void>;
localEntries: LocalFavoriteEntry[];
toggle: (propertyId: string, snapshot?: LocalFavoriteEntry) => Promise<void>;
isLoading: boolean;
}
@ -13,28 +36,72 @@ const FavoritesContext = createContext<FavoritesContextValue | null>(null);
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [localEntries, setLocalEntries] = useState<LocalFavoriteEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
setFavoriteIds(new Set());
const entries = readLocal();
setFavoriteIds(new Set(entries.map(e => e.id)));
setLocalEntries(entries);
return;
}
setIsLoading(true);
getFavorites()
.then(saved => {
// saved is SavedProperty[] — need property_id values
const ids = saved
.then(async saved => {
const serverIds = new Set(
saved.filter((s: any) => s.property_id).map((s: any) => s.property_id as string)
);
// Merge local favorites → server
const local = readLocal();
const toSync = local.filter(e => !serverIds.has(e.id));
if (toSync.length > 0) {
const results = await Promise.allSettled(toSync.map(e => addFavorite(e.id)));
// Only remove from localStorage the IDs that were successfully synced
const syncedIds = new Set(
toSync.filter((_, i) => results[i].status === 'fulfilled').map(e => e.id)
);
const remaining = local.filter(e => !syncedIds.has(e.id));
writeLocal(remaining);
setLocalEntries(remaining);
}
// Refresh from server
const fresh = await getFavorites();
const ids = fresh
.filter((s: any) => s.property_id)
.map((s: any) => s.property_id as string);
setFavoriteIds(new Set(ids));
})
.catch(() => setFavoriteIds(new Set()))
.catch(() => {
// Don't wipe favoriteIds — just keep whatever state we have
})
.finally(() => setIsLoading(false));
}, [isAuthenticated]);
const toggle = useCallback(async (propertyId: string) => {
if (!isAuthenticated) return;
const toggle = useCallback(async (propertyId: string, snapshot?: LocalFavoriteEntry) => {
if (!isAuthenticated) {
const entries = readLocal();
const idx = entries.findIndex(e => e.id === propertyId);
let next: LocalFavoriteEntry[];
if (idx >= 0) {
next = entries.filter(e => e.id !== propertyId);
} else {
const entry: LocalFavoriteEntry = snapshot ?? {
id: propertyId, slug: '', title: 'Imóvel', price: '',
type: 'venda', photo: null, city: null, bedrooms: 0, area_m2: 0,
};
next = [...entries, entry];
}
writeLocal(next);
setLocalEntries(next);
setFavoriteIds(new Set(next.map(e => e.id)));
return;
}
const wasIn = favoriteIds.has(propertyId);
// Optimistic update
setFavoriteIds(prev => {
@ -58,7 +125,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
}, [isAuthenticated, favoriteIds]);
return (
<FavoritesContext.Provider value={{ favoriteIds, toggle, isLoading }}>
<FavoritesContext.Provider value={{ favoriteIds, localEntries, toggle, isLoading }}>
{children}
</FavoritesContext.Provider>
);

View file

@ -0,0 +1,22 @@
import { useEffect, useRef, useState } from 'react'
export function useInView(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setInView(true)
observer.disconnect()
}
}, options)
observer.observe(el)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return { ref, inView }
}

View file

@ -118,6 +118,92 @@
}
@layer components {
/* ─── Navbar shared utilities ─────────────────────────────────────────── */
/* Desktop nav link */
.navbar-link {
@apply text-sm font-medium transition-colors duration-150 text-textSecondary hover:text-textPrimary;
}
.navbar-link--active {
@apply text-textPrimary;
}
/* Desktop trigger button (client / admin dropdown) */
.navbar-trigger {
@apply flex items-center gap-1.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium min-h-[44px] px-1 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60;
}
.navbar-trigger--admin {
@apply font-semibold;
color: var(--color-admin);
}
.navbar-trigger--admin:hover {
opacity: 0.8;
}
/* Truncated username in client trigger */
.navbar-username {
@apply max-w-[96px] truncate leading-none;
}
/* Desktop dropdown item */
.navbar-dropdown-item {
@apply block px-4 py-2 text-sm transition-colors text-textSecondary hover:text-textPrimary hover:bg-surface;
}
.navbar-dropdown-item--active {
@apply bg-surface text-textPrimary font-medium;
}
.navbar-dropdown-item--admin {
color: color-mix(in srgb, var(--color-admin) 70%, transparent);
}
.navbar-dropdown-item--admin:hover {
color: var(--color-admin);
background-color: color-mix(in srgb, var(--color-admin) 6%, transparent);
}
.navbar-dropdown-item--admin-active {
@apply font-semibold;
color: var(--color-admin);
background-color: color-mix(in srgb, var(--color-admin) 10%, transparent);
}
.navbar-dropdown-item--logout {
@apply text-textTertiary hover:text-textPrimary hover:bg-surface;
}
/* CTA buttons */
.navbar-cta {
@apply rounded-lg border border-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-[#5e6ad2] transition hover:bg-[#5e6ad2] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5e6ad2]/60;
}
.navbar-cta--primary {
@apply rounded-lg bg-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-white transition hover:bg-[#6872d8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5e6ad2]/60;
}
/* Mobile menu link */
.navbar-mobile-link {
@apply block py-2.5 min-h-[44px] flex items-center text-sm font-medium transition-colors text-textSecondary hover:text-textPrimary;
}
.navbar-mobile-link--active {
@apply text-textPrimary;
}
/* Hamburger button — 44×44 touch target */
.navbar-hamburger {
@apply flex flex-col gap-1.5 p-2 rounded text-textSecondary hover:text-textPrimary transition-colors min-h-[44px] min-w-[44px] items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60;
}
/* Generic touch target helper */
.navbar-touch-target {
@apply min-h-[44px] min-w-[44px] flex items-center justify-center;
}
.btn-primary {
@apply inline-flex items-center justify-center px-5 py-2.5 bg-brand hover:bg-accentHover text-white font-medium text-sm rounded transition-colors duration-200;
font-feature-settings: "cv01", "ss03";
@ -140,9 +226,7 @@
/* Inputs de formulário theme-aware */
.form-input {
@apply w-full rounded-lg border border-borderPrimary bg-surface px-3 py-2 text-sm text-textPrimary placeholder:text-textQuaternary
focus:border-accent/60 focus:outline-none focus:ring-1 focus:ring-accent/30
transition-colors;
@apply w-full rounded-lg border border-borderPrimary bg-surface px-3 py-2 text-sm text-textPrimary placeholder:text-textQuaternary focus:border-accent/60 focus:outline-none focus:ring-1 focus:ring-accent/30 transition-colors;
}
/* Labels de formulário theme-aware */
@ -157,12 +241,14 @@
}
@layer utilities {
/* Stagger entry animation for property cards */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
@ -179,3 +265,8 @@
}
}
}
@keyframes fadeDown {
0%, 100% { opacity: 0; transform: translateY(-4px); }
50% { opacity: 1; transform: translateY(4px); }
}

View file

@ -1,132 +1,13 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { ThemeToggle } from '../components/ThemeToggle';
const navItems = [
{ to: '/area-do-cliente', label: 'Painel', end: true, icon: '⊞' },
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false, icon: '♡' },
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false, icon: '⇄' },
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false, icon: '📅' },
{ to: '/area-do-cliente/boletos', label: 'Boletos', end: false, icon: '📄' },
];
const adminNavItems = [
{ to: '/admin', label: 'Admin', end: false, icon: '⚙️' },
];
import { Outlet } from 'react-router-dom';
import Navbar from '../components/Navbar';
export default function ClientLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const isAdmin = user?.role === 'admin';
function handleLogout() {
logout();
navigate('/');
}
// Adiciona pt-14 para compensar o header fixo (Navbar)
return (
<div className="flex min-h-screen bg-canvas pt-14">
{/* Sidebar */}
<aside className="hidden lg:flex w-56 flex-col border-r border-borderSubtle bg-panel px-3 py-6">
{/* Theme toggle */}
<div className="flex items-center justify-between mb-6 px-2">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand text-sm font-medium text-white shrink-0">
{user?.name?.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-textPrimary">{user?.name}</p>
<p className="truncate text-xs text-textSecondary">{user?.email}</p>
</div>
</div>
<ThemeToggle />
</div>
<div className="min-h-screen bg-canvas pt-14">
<Navbar />
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-0.5">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition ${isActive
? 'bg-surface text-textPrimary font-medium'
: 'text-textSecondary hover:text-textPrimary hover:bg-surface'
}`
}
>
<span className="text-base">{item.icon}</span>
{item.label}
</NavLink>
))}
{isAdmin && adminNavItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition ${isActive
? 'bg-[#f5c518] text-black font-semibold'
: 'text-[#f5c518] hover:text-black hover:bg-[#ffe066]'
}`
}
>
<span className="text-base">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
{/* Logout */}
<button
onClick={handleLogout}
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-textTertiary hover:text-textPrimary hover:bg-surface transition mt-4"
>
<span></span>Sair
</button>
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 overflow-auto">
{/* Mobile nav */}
<div className="lg:hidden border-b border-borderSubtle bg-panel overflow-x-auto flex items-center justify-between px-2 py-2">
<div className="flex gap-0.5">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`shrink-0 rounded-lg px-3 py-1.5 text-xs transition ${isActive
? 'bg-surface text-textPrimary font-medium'
: 'text-textSecondary hover:text-textPrimary'
}`
}
>
{item.label}
</NavLink>
))}
{isAdmin && adminNavItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold transition ${isActive
? 'bg-[#f5c518] text-black'
: 'text-[#f5c518] hover:text-black hover:bg-[#ffe066]'
}`
}
>
{item.label}
</NavLink>
))}
</div>
<ThemeToggle />
</div>
<main className="mx-auto w-full max-w-7xl min-w-0 overflow-auto">
<Outlet />
</main>
</div>

View file

@ -0,0 +1,432 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
import { submitGeneralContact } from '../services/properties'
const TIPOS_IMOVEL = [
'Apartamento',
'Casa',
'Casa de condomínio',
'Cobertura',
'Flat / Studio',
'Terreno',
'Sala comercial',
'Galpão',
'Outro',
]
interface FormState {
name: string
phone: string
email: string
finalidade: string
tipo_imovel: string
valor: string
valor_condominio: string
area_interna: string
quartos: string
suites: string
banheiros: string
vagas: string
aceita_permuta: boolean
aceita_financiamento: boolean
ocupado: boolean
cep: string
logradouro: string
numero: string
bairro: string
cidade: string
complemento: string
message: string
privacy: boolean
}
const INITIAL: FormState = {
name: '', phone: '', email: '',
finalidade: '', tipo_imovel: '', valor: '', valor_condominio: '',
area_interna: '', quartos: '', suites: '', banheiros: '', vagas: '',
aceita_permuta: false, aceita_financiamento: false, ocupado: false,
cep: '', logradouro: '', numero: '', bairro: '', cidade: '', complemento: '',
message: '', privacy: false,
}
const inputCls = 'w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40'
const labelCls = 'block text-xs font-medium text-textSecondary mb-1'
const STEPS = [
{
num: '01',
title: 'Preencha o formulário',
desc: 'Informe os dados do seu imóvel e seus dados de contato no formulário abaixo.',
},
{
num: '02',
title: 'Captador especialista',
desc: 'Suas informações serão direcionadas para um de nossos corretores especializados.',
},
{
num: '03',
title: 'Avaliação e anúncio',
desc: 'Nosso corretor agendará uma visita para avaliar o imóvel e iniciar o processo de anúncio.',
},
]
const BENEFICIOS = [
{
icon: (
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
title: 'Atendimento qualificado',
desc: 'Corretores experientes e dedicados ao seu imóvel.',
},
{
icon: (
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
title: 'Maior visibilidade',
desc: 'Anúncios nos principais portais do mercado imobiliário.',
},
{
icon: (
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
title: 'Melhor negociação',
desc: 'Agilidade no processo e suporte completo até o fechamento.',
},
]
export default function CadastroResidenciaPage() {
const [form, setForm] = useState<FormState>(INITIAL)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) {
const { name, value, type } = e.target
const checked = (e.target as HTMLInputElement).checked
setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.privacy) {
setError('Você precisa aceitar a Política de Privacidade para continuar.')
return
}
setLoading(true)
setError(null)
try {
const parts: string[] = []
if (form.finalidade) parts.push(`Finalidade: ${form.finalidade}`)
if (form.tipo_imovel) parts.push(`Tipo: ${form.tipo_imovel}`)
if (form.valor) parts.push(`Valor: R$ ${form.valor}`)
if (form.valor_condominio) parts.push(`Condomínio: R$ ${form.valor_condominio}`)
if (form.area_interna) parts.push(`Área interna: ${form.area_interna}`)
if (form.quartos) parts.push(`Quartos: ${form.quartos}`)
if (form.suites) parts.push(`Suítes: ${form.suites}`)
if (form.banheiros) parts.push(`Banheiros: ${form.banheiros}`)
if (form.vagas) parts.push(`Vagas: ${form.vagas}`)
const flags = [
form.aceita_permuta && 'Aceita permuta',
form.aceita_financiamento && 'Aceita financiamento',
form.ocupado && 'Imóvel ocupado',
].filter(Boolean)
if (flags.length) parts.push(flags.join(' | '))
const endereco = [form.logradouro, form.numero, form.bairro, form.cidade, form.cep, form.complemento]
.filter(Boolean).join(', ')
if (endereco) parts.push(`Endereço: ${endereco}`)
if (form.message) parts.push(form.message)
await submitGeneralContact({
name: form.name,
email: form.email,
phone: form.phone,
message: parts.join('\n') || 'Cadastro de imóvel.',
source: 'cadastro_residencia',
source_detail: form.tipo_imovel || undefined,
})
setSuccess(true)
setForm(INITIAL)
} catch {
setError('Não foi possível enviar seu cadastro. Tente novamente.')
} finally {
setLoading(false)
}
}
return (
<>
<Navbar />
<main id="main-content" className="min-h-screen bg-canvas">
{/* Hero */}
<section className="bg-surface border-b border-borderSubtle">
<div className="max-w-[1080px] mx-auto px-6 pt-24 pb-16">
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
Quero anunciar
</p>
<h1
className="text-3xl md:text-5xl font-semibold text-textPrimary tracking-tight max-w-[680px] leading-tight"
style={{ fontFeatureSettings: '"cv01","ss03"' }}
>
Ajudamos você a vender ou alugar seu imóvel com rapidez
</h1>
<p className="mt-5 text-textSecondary text-base md:text-lg leading-relaxed max-w-[560px]">
Anuncie conosco e tenha acesso aos melhores portais do mercado imobiliário,
com atendimento especializado do início ao fechamento.
</p>
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-6">
{STEPS.map((s) => (
<div key={s.num} className="flex gap-4">
<span className="text-3xl font-bold text-[#5e6ad2]/20 leading-none shrink-0 select-none">
{s.num}
</span>
<div>
<p className="text-sm font-semibold text-textPrimary">{s.title}</p>
<p className="text-xs text-textTertiary mt-1 leading-relaxed">{s.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* Benefícios */}
<section className="max-w-[1080px] mx-auto px-6 py-14">
<h2 className="text-lg font-semibold text-textPrimary mb-8 text-center">
Benefícios que oferecemos para você
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{BENEFICIOS.map((b) => (
<div
key={b.title}
className="bg-panel border border-borderSubtle rounded-xl p-6 flex flex-col items-center text-center gap-3"
>
<div className="w-14 h-14 rounded-full bg-[#5e6ad2]/10 flex items-center justify-center text-[#5e6ad2]">
{b.icon}
</div>
<p className="text-sm font-semibold text-textPrimary">{b.title}</p>
<p className="text-xs text-textTertiary leading-relaxed">{b.desc}</p>
</div>
))}
</div>
</section>
{/* Formulário */}
<section className="max-w-[800px] mx-auto px-6 pb-24">
<p className="text-center text-textSecondary text-sm mb-8">
Preencha o formulário abaixo e anuncie seu imóvel conosco!
</p>
<div className="bg-panel border border-borderSubtle rounded-2xl p-8">
{success ? (
<div className="text-center py-12">
<div className="w-14 h-14 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-xl font-semibold text-textPrimary mb-2">Cadastro enviado!</h3>
<p className="text-textSecondary text-sm mb-6 max-w-[360px] mx-auto">
Recebemos suas informações. Em breve um corretor especialista entrará em contato.
</p>
<button
onClick={() => setSuccess(false)}
className="text-sm text-[#5e6ad2] hover:underline"
>
Cadastrar outro imóvel
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-8" noValidate>
{/* Dados pessoais */}
<div>
<h3 className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-4 pb-2 border-b border-borderSubtle">
Dados Pessoais
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Nome <span className="text-red-400">*</span></label>
<input type="text" name="name" value={form.name} onChange={handleChange} required placeholder="Seu nome completo" className={inputCls} />
</div>
<div>
<label className={labelCls}>Telefone <span className="text-red-400">*</span></label>
<input type="tel" name="phone" value={form.phone} onChange={handleChange} required placeholder="(11) 99999-0000" className={inputCls} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>E-mail <span className="text-red-400">*</span></label>
<input type="email" name="email" value={form.email} onChange={handleChange} required placeholder="seu@email.com" className={inputCls} />
</div>
</div>
</div>
{/* Dados do imóvel */}
<div>
<h3 className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-4 pb-2 border-b border-borderSubtle">
Dados do Imóvel
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Finalidade <span className="text-red-400">*</span></label>
<select name="finalidade" value={form.finalidade} onChange={handleChange} required className={inputCls}>
<option value="">Selecione</option>
<option>Venda</option>
<option>Locação</option>
<option>Venda e Locação</option>
</select>
</div>
<div>
<label className={labelCls}>Tipo do imóvel</label>
<select name="tipo_imovel" value={form.tipo_imovel} onChange={handleChange} className={inputCls}>
<option value="">Selecione</option>
{TIPOS_IMOVEL.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelCls}>Valor (R$)</label>
<input type="text" name="valor" value={form.valor} onChange={handleChange} placeholder="Ex: 450.000" className={inputCls} />
</div>
<div>
<label className={labelCls}>Valor do condomínio (R$)</label>
<input type="text" name="valor_condominio" value={form.valor_condominio} onChange={handleChange} placeholder="Ex: 600" className={inputCls} />
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<label className={labelCls}>Área interna (m²)</label>
<input type="number" name="area_interna" value={form.area_interna} onChange={handleChange} min={1} placeholder="85" className={inputCls} />
</div>
<div>
<label className={labelCls}>Quartos</label>
<input type="number" name="quartos" value={form.quartos} onChange={handleChange} min={0} placeholder="3" className={inputCls} />
</div>
<div>
<label className={labelCls}>Suítes</label>
<input type="number" name="suites" value={form.suites} onChange={handleChange} min={0} placeholder="1" className={inputCls} />
</div>
<div>
<label className={labelCls}>Vagas</label>
<input type="number" name="vagas" value={form.vagas} onChange={handleChange} min={0} placeholder="2" className={inputCls} />
</div>
</div>
<div className="flex flex-wrap gap-5 pt-1">
{[
{ name: 'aceita_permuta', label: 'Aceita permuta' },
{ name: 'aceita_financiamento', label: 'Aceita financiamento' },
{ name: 'ocupado', label: 'Imóvel ocupado' },
].map(({ name, label }) => (
<label key={name} className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
name={name}
checked={form[name as keyof FormState] as boolean}
onChange={handleChange}
className="w-4 h-4 accent-[#5e6ad2] rounded"
/>
<span className="text-sm text-textSecondary">{label}</span>
</label>
))}
</div>
</div>
</div>
{/* Endereço */}
<div>
<h3 className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-4 pb-2 border-b border-borderSubtle">
Endereço do Imóvel
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={labelCls}>CEP</label>
<input type="text" name="cep" value={form.cep} onChange={handleChange} placeholder="00000-000" className={inputCls} />
</div>
<div className="sm:col-span-2">
<label className={labelCls}>Logradouro</label>
<input type="text" name="logradouro" value={form.logradouro} onChange={handleChange} placeholder="Rua, Avenida…" className={inputCls} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={labelCls}>Número</label>
<input type="text" name="numero" value={form.numero} onChange={handleChange} placeholder="123" className={inputCls} />
</div>
<div>
<label className={labelCls}>Bairro</label>
<input type="text" name="bairro" value={form.bairro} onChange={handleChange} placeholder="Centro" className={inputCls} />
</div>
<div>
<label className={labelCls}>Cidade</label>
<input type="text" name="cidade" value={form.cidade} onChange={handleChange} placeholder="São Paulo" className={inputCls} />
</div>
</div>
<div>
<label className={labelCls}>Complemento</label>
<input type="text" name="complemento" value={form.complemento} onChange={handleChange} placeholder="Ap. 42, Bloco B…" className={inputCls} />
</div>
</div>
</div>
{/* Observações */}
<div>
<label className={labelCls}>Informações adicionais</label>
<textarea
name="message"
value={form.message}
onChange={handleChange}
rows={3}
placeholder="Descreva características relevantes do imóvel…"
className={`${inputCls} resize-none`}
/>
</div>
{/* Privacidade + envio */}
<div className="space-y-4">
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
name="privacy"
checked={form.privacy}
onChange={handleChange}
className="mt-0.5 w-4 h-4 accent-[#5e6ad2] shrink-0"
/>
<span className="text-xs text-textTertiary leading-relaxed">
Ao informar meus dados, concordo com a{' '}
<Link to="/politica-de-privacidade" className="text-[#5e6ad2] hover:underline">
Política de Privacidade
</Link>
.
</span>
</label>
{error && (
<p className="text-red-400 text-xs">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-brand hover:bg-accentHover text-white font-semibold text-sm rounded-lg py-3 transition-colors disabled:opacity-60 uppercase tracking-wide"
>
{loading ? 'Enviando…' : 'Anunciar imóvel'}
</button>
</div>
</form>
)}
</div>
</section>
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,260 @@
import { useEffect, useState } from 'react'
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
import { getContactConfig, type ContactConfig } from '../services/contactConfig'
import { submitGeneralContact } from '../services/properties'
const ASSUNTOS = [
'Quero comprar um imóvel',
'Quero alugar um imóvel',
'Quero vender meu imóvel',
'Tenho dúvidas gerais',
'Outro assunto',
]
interface FormState {
name: string
email: string
phone: string
subject: string
message: string
}
const INITIAL: FormState = {
name: '',
email: '',
phone: '',
subject: '',
message: '',
}
export default function ContactPage() {
const [form, setForm] = useState<FormState>(INITIAL)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<ContactConfig | null>(null)
useEffect(() => {
getContactConfig().then(setInfo).catch(() => {/* silently keep null */ })
}, [])
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
await submitGeneralContact({
name: form.name,
email: form.email,
phone: form.phone,
message: form.subject ? `[${form.subject}] ${form.message}` : form.message,
source: 'contato',
source_detail: form.subject || undefined,
})
setSuccess(true)
setForm(INITIAL)
} catch {
setError('Não foi possível enviar sua mensagem. Tente novamente.')
} finally {
setLoading(false)
}
}
return (
<>
<Navbar />
<main id="main-content" className="min-h-screen bg-canvas">
<section className="max-w-[1080px] mx-auto px-6 pt-20 pb-16">
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
Fale conosco
</p>
<h1
className="text-3xl md:text-5xl font-semibold text-textPrimary tracking-tight max-w-[640px] leading-tight"
style={{ fontFeatureSettings: '"cv01","ss03"' }}
>
Entre em contato
</h1>
<p className="mt-4 text-textSecondary text-base md:text-lg leading-relaxed max-w-[560px]">
Nossa equipe está pronta para ajudá-lo. Preencha o formulário e retornaremos em até 24 horas.
</p>
</section>
<section className="max-w-[1080px] mx-auto px-6 pb-24 grid md:grid-cols-2 gap-12">
{/* ── Info ─────────────────────────────────────────── */}
<div className="space-y-8">
<div>
<h2 className="text-lg font-semibold text-textPrimary mb-4">Nosso escritório</h2>
<ul className="space-y-4 text-textSecondary text-sm">
{(info?.address_street || info?.address_neighborhood_city || info?.address_zip) && (
<li className="flex items-start gap-3">
<svg className="w-5 h-5 mt-0.5 text-[#5e6ad2] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>
{info?.address_street && <>{info.address_street}<br /></>}
{info?.address_neighborhood_city && <>{info.address_neighborhood_city}<br /></>}
{info?.address_zip}
</span>
</li>
)}
{info?.phone && (
<li className="flex items-center gap-3">
<svg className="w-5 h-5 text-[#5e6ad2] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>{info.phone}</span>
</li>
)}
{info?.email && (
<li className="flex items-center gap-3">
<svg className="w-5 h-5 text-[#5e6ad2] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>{info.email}</span>
</li>
)}
</ul>
</div>
{info?.business_hours && (
<div>
<h2 className="text-lg font-semibold text-textPrimary mb-2">Horário de atendimento</h2>
<p className="text-textSecondary text-sm leading-relaxed" style={{ whiteSpace: 'pre-line' }}>
{info.business_hours}
</p>
</div>
)}
<div>
<p className="text-xs text-textTertiary">
Deseja cadastrar seu imóvel conosco?{' '}
<a href="/cadastro-residencia" className="text-[#5e6ad2] hover:underline font-medium">
Clique aqui
</a>
</p>
</div>
</div>
{/* ── Form ─────────────────────────────────────────── */}
<div className="bg-panel border border-borderSubtle rounded-2xl p-8">
{success ? (
<div className="text-center py-8">
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-textPrimary mb-2">Mensagem enviada!</h3>
<p className="text-textSecondary text-sm mb-6">Retornaremos em breve pelo e-mail informado.</p>
<button
onClick={() => setSuccess(false)}
className="text-sm text-[#5e6ad2] hover:underline"
>
Enviar outra mensagem
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Nome <span className="text-red-400">*</span>
</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
required
placeholder="Seu nome"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Telefone
</label>
<input
type="tel"
name="phone"
value={form.phone}
onChange={handleChange}
placeholder="(11) 99999-0000"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
E-mail <span className="text-red-400">*</span>
</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
placeholder="seu@email.com"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Assunto
</label>
<select
name="subject"
value={form.subject}
onChange={handleChange}
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
>
<option value="">Selecione um assunto</option>
{ASSUNTOS.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Mensagem <span className="text-red-400">*</span>
</label>
<textarea
name="message"
value={form.message}
onChange={handleChange}
required
rows={5}
placeholder="Descreva como podemos ajudá-lo…"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 resize-none"
/>
</div>
{error && (
<p className="text-red-400 text-xs">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-brand hover:bg-accentHover text-white font-medium text-sm rounded-lg py-2.5 transition-colors disabled:opacity-60"
>
{loading ? 'Enviando…' : 'Enviar mensagem'}
</button>
</form>
)}
</div>
</section>
</main>
<Footer />
</>
)
}

View file

@ -3,8 +3,13 @@ import AgentsCarousel from '../components/AgentsCarousel'
import Footer from '../components/Footer'
import HomeScrollScene from '../components/HomeScrollScene'
import Navbar from '../components/Navbar'
import { useTheme } from '../contexts/ThemeContext'
import { getHomepageConfig } from '../services/homepage'
import { getFeaturedProperties } from '../services/properties'
import { getAgents } from '../services/agents'
import type { HomepageConfig } from '../types/homepage'
import type { Property } from '../types/property'
import type { Agent } from '../types/agent'
const FALLBACK_CONFIG: HomepageConfig = {
hero_headline: 'Encontre o imóvel dos seus sonhos',
@ -14,23 +19,68 @@ const FALLBACK_CONFIG: HomepageConfig = {
featured_properties_limit: 6,
}
const CFG_CACHE_KEY = 'homepage_config_v1'
const CFG_CACHE_TTL = 5 * 60 * 1000
function getCachedConfig(): HomepageConfig | null {
try {
const raw = sessionStorage.getItem(CFG_CACHE_KEY)
if (!raw) return null
const { data, ts } = JSON.parse(raw) as { data: HomepageConfig; ts: number }
if (Date.now() - ts > CFG_CACHE_TTL) return null
return data
} catch { return null }
}
function setCachedConfig(data: HomepageConfig): void {
try {
sessionStorage.setItem(CFG_CACHE_KEY, JSON.stringify({ data, ts: Date.now() }))
} catch {}
}
export default function HomePage() {
const [config, setConfig] = useState<HomepageConfig>(FALLBACK_CONFIG)
const [isLoading, setIsLoading] = useState(true)
const [featuredProperties, setFeaturedProperties] = useState<Property[]>([])
const [loadingProperties, setLoadingProperties] = useState(true)
const [agents, setAgents] = useState<Agent[]>([])
const [loadingAgents, setLoadingAgents] = useState(true)
const { resolvedTheme } = useTheme()
const themedBackgroundImage = resolvedTheme === 'dark'
? (config.hero_image_dark_url ?? config.hero_image_url ?? null)
: (config.hero_image_light_url ?? config.hero_image_url ?? null)
useEffect(() => {
getHomepageConfig()
.then((data) => {
setConfig(data)
})
.catch(() => {
// Silently fall back to FALLBACK_CONFIG — already set in useState
const cached = getCachedConfig()
const configFetch = cached
? Promise.resolve(cached)
: getHomepageConfig().then(d => { setCachedConfig(d); return d })
Promise.all([configFetch, getFeaturedProperties(), getAgents()])
.then(([cfg, props, agts]) => {
setConfig(cfg)
setFeaturedProperties(props)
setAgents(agts)
})
.catch(() => {})
.finally(() => {
setIsLoading(false)
setLoadingProperties(false)
setLoadingAgents(false)
})
}, [])
useEffect(() => {
if (!themedBackgroundImage) return
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'image'
link.href = themedBackgroundImage
document.head.appendChild(link)
return () => { document.head.removeChild(link) }
}, [themedBackgroundImage])
return (
<>
<Navbar />
@ -40,8 +90,10 @@ export default function HomePage() {
subheadline={config.hero_subheadline ?? null}
ctaLabel={config.hero_cta_label}
ctaUrl={config.hero_cta_url}
backgroundImage={config.hero_image_url ?? null}
backgroundImage={themedBackgroundImage}
isLoading={isLoading}
properties={featuredProperties}
loadingProperties={loadingProperties}
/>
{/* ── Corretores Carousel ───────────────────────────────────── */}
@ -67,7 +119,7 @@ export default function HomePage() {
</svg>
</a>
</div>
<AgentsCarousel />
<AgentsCarousel agents={agents} loading={loadingAgents} />
</div>
</div>
</main>

View file

@ -0,0 +1,316 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
import { submitJobApplication, type JobApplicationPayload } from '../services/jobs'
const ROLE_OPTIONS = [
'Corretor(a)',
'Assistente Administrativo',
'Estagiário(a)',
'Outro',
]
const BENEFITS = [
{
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
title: 'Equipe colaborativa',
description: 'Trabalhe com profissionais experientes em um ambiente de apoio mútuo e crescimento constante.',
},
{
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
),
title: 'Crescimento real',
description: 'Plano de carreira claro, metas atingíveis e reconhecimento de resultados individuais e coletivos.',
},
{
icon: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><path d="M12 8v4l3 3" />
</svg>
),
title: 'Flexibilidade',
description: 'Horários adaptáveis, comissionamento competitivo e autonomia para gerenciar sua agenda.',
},
]
interface FormState {
name: string
email: string
phone: string
role_interest: string
message: string
file_name: string
privacy: boolean
}
const INITIAL: FormState = {
name: '',
email: '',
phone: '',
role_interest: '',
message: '',
file_name: '',
privacy: false,
}
function InputField({
label, name, type = 'text', required = false, value, onChange, placeholder,
}: {
label: string
name: string
type?: string
required?: boolean
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
placeholder?: string
}) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={name} className="text-sm font-medium text-textSecondary">
{label}{required && <span className="text-red-400 ml-0.5">*</span>}
</label>
<input
id={name}
name={name}
type={type}
required={required}
value={value}
onChange={onChange}
placeholder={placeholder}
className="h-10 rounded-lg border border-borderSubtle bg-surface px-3 text-sm text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 focus:border-[#5e6ad2] transition"
/>
</div>
)
}
export default function JobsPage() {
const [form, setForm] = useState<FormState>(INITIAL)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) {
const { name, value, type } = e.target
if (type === 'checkbox') {
setForm((prev) => ({ ...prev, [name]: (e.target as HTMLInputElement).checked }))
} else {
setForm((prev) => ({ ...prev, [name]: value }))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.privacy) {
setError('Você precisa aceitar a política de privacidade.')
return
}
setLoading(true)
setError(null)
try {
const payload: JobApplicationPayload = {
name: form.name,
email: form.email,
phone: form.phone || undefined,
role_interest: form.role_interest,
message: form.message,
file_name: form.file_name || undefined,
}
await submitJobApplication(payload)
setSuccess(true)
setForm(INITIAL)
} catch {
setError('Não foi possível enviar sua candidatura. Tente novamente.')
} finally {
setLoading(false)
}
}
return (
<>
<Navbar />
<main className="min-h-screen bg-canvas pt-14">
{/* Hero */}
<section className="bg-panel border-b border-borderSubtle">
<div className="max-w-[760px] mx-auto px-6 py-16 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#5e6ad2]/30 bg-[#5e6ad2]/10 px-3 py-1 text-xs font-medium text-[#5e6ad2] mb-5">
Oportunidades
</span>
<h1 className="text-3xl sm:text-4xl font-semibold text-textPrimary tracking-tight mb-4">
Trabalhe Conosco
</h1>
<p className="text-base text-textSecondary leading-relaxed max-w-[540px] mx-auto">
Faça parte de um time apaixonado pelo mercado imobiliário. Envie seu currículo e conte-nos por que você quer crescer com a gente.
</p>
</div>
</section>
{/* Benefícios */}
<section className="max-w-[1000px] mx-auto px-6 py-14">
<h2 className="text-lg font-semibold text-textPrimary mb-8 text-center">
Por que trabalhar conosco?
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{BENEFITS.map((b) => (
<div key={b.title} className="rounded-xl border border-borderSubtle bg-panel p-6 flex flex-col gap-3">
<span className="text-[#5e6ad2]">{b.icon}</span>
<h3 className="text-sm font-semibold text-textPrimary">{b.title}</h3>
<p className="text-sm text-textTertiary leading-relaxed">{b.description}</p>
</div>
))}
</div>
</section>
{/* Formulário */}
<section className="max-w-[680px] mx-auto px-6 pb-20">
<div className="rounded-xl border border-borderSubtle bg-panel p-8">
<h2 className="text-lg font-semibold text-textPrimary mb-1">
Envie sua candidatura
</h2>
<p className="text-sm text-textTertiary mb-6">
Preencha os campos abaixo e entraremos em contato.
</p>
{success ? (
<div className="rounded-lg border border-green-500/30 bg-green-500/10 p-6 text-center">
<svg className="mx-auto mb-3 text-green-400" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p className="text-sm font-medium text-green-400">Candidatura enviada com sucesso!</p>
<p className="text-xs text-textTertiary mt-1">Entraremos em contato em breve.</p>
<button
onClick={() => setSuccess(false)}
className="mt-4 text-xs text-[#5e6ad2] hover:underline"
>
Enviar outra candidatura
</button>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-5">
{/* Dados pessoais */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InputField
label="Nome completo"
name="name"
required
value={form.name}
onChange={handleChange}
placeholder="Seu nome"
/>
<InputField
label="E-mail"
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
placeholder="seu@email.com"
/>
<InputField
label="Telefone"
name="phone"
type="tel"
value={form.phone}
onChange={handleChange}
placeholder="(11) 99999-9999"
/>
{/* Cargo de interesse */}
<div className="flex flex-col gap-1.5">
<label htmlFor="role_interest" className="text-sm font-medium text-textSecondary">
Cargo de interesse<span className="text-red-400 ml-0.5">*</span>
</label>
<select
id="role_interest"
name="role_interest"
required
value={form.role_interest}
onChange={handleChange}
className="h-10 rounded-lg border border-borderSubtle bg-surface px-3 text-sm text-textPrimary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 focus:border-[#5e6ad2] transition"
>
<option value="">Selecione</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
</div>
{/* Nome do arquivo */}
<InputField
label="Nome do arquivo do currículo (PDF)"
name="file_name"
value={form.file_name}
onChange={handleChange}
placeholder="curriculo_joao_silva.pdf"
/>
{/* Mensagem */}
<div className="flex flex-col gap-1.5">
<label htmlFor="message" className="text-sm font-medium text-textSecondary">
Apresentação<span className="text-red-400 ml-0.5">*</span>
</label>
<textarea
id="message"
name="message"
required
rows={5}
maxLength={5000}
value={form.message}
onChange={handleChange}
placeholder="Fale um pouco sobre você, sua experiência e por que quer trabalhar conosco…"
className="rounded-lg border border-borderSubtle bg-surface px-3 py-2.5 text-sm text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 focus:border-[#5e6ad2] transition resize-none"
/>
<span className="text-xs text-textQuaternary text-right">
{form.message.length}/5000
</span>
</div>
{/* Política */}
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
name="privacy"
checked={form.privacy}
onChange={handleChange}
className="mt-0.5 h-4 w-4 rounded border-borderSubtle accent-[#5e6ad2]"
/>
<span className="text-xs text-textTertiary leading-relaxed">
Li e aceito a{' '}
<Link to="/politica-de-privacidade" className="text-[#5e6ad2] hover:underline" target="_blank" rel="noopener noreferrer">
Política de Privacidade
</Link>
.
</span>
</label>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#5e6ad2] py-2.5 text-sm font-semibold text-white hover:bg-[#6872d8] transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading ? 'Enviando…' : 'ENVIAR CANDIDATURA'}
</button>
</form>
)}
</div>
</section>
</main>
<Footer />
</>
)
}

View file

@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import Navbar from '../components/Navbar'
import { useAuth } from '../contexts/AuthContext'
export default function LoginPage() {
@ -34,73 +35,117 @@ export default function LoginPage() {
}
}
const demoCredentials = [
{ label: 'Admin', email: 'admin@demo.com', password: 'admin1234', admin: true },
{ label: 'Usuário', email: 'usuario@demo.com', password: 'demo1234', admin: false },
]
function fillCredentials(cred: typeof demoCredentials[0]) {
setEmail(cred.email)
setPassword(cred.password)
setError('')
}
return (
<div className="flex min-h-screen items-center justify-center bg-canvas px-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-textPrimary">Entrar</h1>
<p className="mt-1 text-sm text-textSecondary">Acesse sua conta</p>
</div>
<div className="min-h-screen bg-canvas">
<Navbar />
<form
onSubmit={handleSubmit}
className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4"
>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
<div className="space-y-1">
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
E-mail
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="form-input"
placeholder="seu@email.com"
/>
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] w-full max-w-sm items-center justify-center px-4 py-8 pt-20">
<div className="w-full">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-textPrimary">Entrar</h1>
<p className="mt-1 text-sm text-textSecondary">Acesse sua conta</p>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
Senha
</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-brand py-2.5 text-sm font-medium text-white transition hover:bg-accentHover disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
<form
onSubmit={handleSubmit}
className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4"
>
{loading && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
<p className="mt-4 text-center text-sm text-textTertiary">
Não tem conta?{' '}
<Link
to="/cadastro"
className="text-accent hover:text-accentHover transition"
>
Cadastre-se
</Link>
</p>
<div className="space-y-1">
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
E-mail
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="form-input"
placeholder="seu@email.com"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
Senha
</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-brand py-2.5 text-sm font-medium text-white transition hover:bg-accentHover disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
)}
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
<p className="mt-4 text-center text-sm text-textTertiary">
Não tem conta?{' '}
<Link
to="/cadastro"
className="text-accent hover:text-accentHover transition"
>
Cadastre-se
</Link>
</p>
{/* Demo credentials */}
<div className="mt-6 rounded-xl border border-borderSubtle bg-panel/60 p-4 space-y-3">
<p className="text-xs font-semibold text-textTertiary uppercase tracking-wide text-center">
Acesso de demonstração
</p>
{demoCredentials.map((cred) => (
<button
key={cred.email}
type="button"
onClick={() => fillCredentials(cred)}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-borderSubtle bg-surface hover:bg-panel px-3 py-2.5 transition text-left"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-textPrimary">{cred.label}</span>
{cred.admin && (
<span className="rounded-full bg-brand/20 text-brand text-[10px] font-semibold px-1.5 py-0.5">
Admin
</span>
)}
</div>
<p className="text-[11px] text-textTertiary truncate">{cred.email}</p>
<p className="text-[11px] text-textTertiary font-mono">{cred.password}</p>
</div>
<span className="text-xs text-accent shrink-0">Usar </span>
</button>
))}
</div>
</div>
</div>
</div>
)

View file

@ -205,6 +205,16 @@ export default function PropertiesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [filters, setFilters] = useState<PropertyFilters>(() => filtersFromParams(searchParams))
// Sync filters when URL changes externally (e.g. navbar "Comprar"/"Alugar" links)
const prevSearchParamsRef = useRef(searchParams.toString())
useEffect(() => {
const current = searchParams.toString()
if (current !== prevSearchParamsRef.current) {
prevSearchParamsRef.current = current
setFilters(filtersFromParams(searchParams))
}
}, [searchParams])
const [result, setResult] = useState<PaginatedProperties | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -363,10 +373,10 @@ export default function PropertiesPage() {
</button>
</div>
{/* Mobile filter button */}
{/* Mobile filter button — only on small screens */}
<button
onClick={() => setSidebarOpen(true)}
className="flex items-center gap-2 h-8 px-3 rounded-lg border border-borderSubtle text-sm text-textSecondary hover:border-borderStandard transition-colors"
className="lg:hidden flex items-center gap-2 h-8 px-3 rounded-lg border border-borderSubtle text-sm text-textSecondary hover:border-borderStandard transition-colors"
>
<FilterIcon />
Filtros
@ -383,116 +393,134 @@ export default function PropertiesPage() {
{/* ── Main content ─────────────────────────────────────────── */}
<div className="max-w-[1400px] mx-auto px-6 py-6">
{/* Results area */}
<div ref={resultsRef} className="w-full">
{/* Active filter chips */}
<ActiveFiltersBar
filters={filters}
catalog={{ propertyTypes, cities, neighborhoods, imobiliarias }}
onFilterChange={handleFiltersChange}
/>
{/* Error state */}
{error && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-textSecondary text-base mb-2">{error}</p>
<button
onClick={() => fetchProperties(filters)}
className="mt-4 text-sm font-medium text-brand hover:underline"
>
Tentar novamente
</button>
<div className="flex gap-6 items-start">
{/* Sidebar fixa — visível apenas em lg+ */}
<aside className="hidden lg:block w-64 flex-shrink-0 sticky top-[120px]">
<div className="bg-panel border border-borderSubtle rounded-xl p-4 overflow-y-auto max-h-[calc(100vh-140px)]">
<FilterSidebar
propertyTypes={propertyTypes}
amenities={amenities}
cities={cities}
neighborhoods={neighborhoods}
imobiliarias={imobiliarias}
filters={filters}
onChange={handleFiltersChange}
onClear={handleClear}
catalogLoading={catalogLoading}
/>
</div>
)}
</aside>
{/* Results area */}
<div ref={resultsRef} className="flex-1 min-w-0">
{/* Active filter chips */}
<ActiveFiltersBar
filters={filters}
catalog={{ propertyTypes, cities, neighborhoods, imobiliarias }}
onFilterChange={handleFiltersChange}
/>
{!error && (
<>
{/* Top pagination (only after first load with multiple pages) */}
{result && result.pages > 1 && !loading && (
<div className="mb-4">
<Pagination
current={result.page}
total={result.pages}
onChange={handlePageChange}
ariaLabel="Paginação superior"
/>
</div>
)}
{/* Error state */}
{error && (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-textSecondary text-base mb-2">{error}</p>
<button
onClick={() => fetchProperties(filters)}
className="mt-4 text-sm font-medium text-brand hover:underline"
>
Tentar novamente
</button>
</div>
)}
{/* First load: skeleton. Filter change: opacity overlay */}
{loading && !result ? (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 9 }).map((_, i) => (
<PropertyGridSkeleton key={i} />
))}
</div>
) : (
<div className="flex flex-col gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<PropertyRowSkeleton key={i} />
))}
</div>
)
) : result && result.items.length > 0 ? (
<div className={`transition-opacity duration-150 ${loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}`}>
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{result.items.map((property, i) => (
<div
key={property.id}
className="animate-fade-in-up"
style={{ animationDelay: `${i * 40}ms` }}
>
<PropertyGridCard property={property} />
</div>
))}
</div>
) : (
<div className="flex flex-col gap-3">
{result.items.map((property, i) => (
<div
key={property.id}
className="animate-fade-in-up"
style={{ animationDelay: `${i * 40}ms` }}
>
<PropertyRowCard property={property} />
</div>
))}
</div>
)}
{/* Position indicator */}
{showPositionIndicator && (
<p className="text-xs text-textTertiary text-center mt-6">
Exibindo {from}{to} de {result.total} imóveis
</p>
)}
{/* Bottom pagination */}
<div className="mt-4">
{!error && (
<>
{/* Top pagination (only after first load with multiple pages) */}
{result && result.pages > 1 && !loading && (
<div className="mb-4">
<Pagination
current={result.page}
total={result.pages}
onChange={handlePageChange}
ariaLabel="Paginação"
ariaLabel="Paginação superior"
/>
</div>
</div>
) : !loading ? (
<EmptyStateWithSuggestions
hasFilters={hasActiveFilters(filters)}
suggestions={suggestions}
onClearAll={handleClear}
/>
) : null}
</>
)}
)}
{/* First load: skeleton. Filter change: opacity overlay */}
{loading && !result ? (
viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 9 }).map((_, i) => (
<PropertyGridSkeleton key={i} />
))}
</div>
) : (
<div className="flex flex-col gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<PropertyRowSkeleton key={i} />
))}
</div>
)
) : result && result.items.length > 0 ? (
<div className={`transition-opacity duration-150 ${loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}`}>
{viewMode === 'grid' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{result.items.map((property, i) => (
<div
key={property.id}
className="animate-fade-in-up"
style={{ animationDelay: `${i * 40}ms` }}
>
<PropertyGridCard property={property} />
</div>
))}
</div>
) : (
<div className="flex flex-col gap-3">
{result.items.map((property, i) => (
<div
key={property.id}
className="animate-fade-in-up"
style={{ animationDelay: `${i * 40}ms` }}
>
<PropertyRowCard property={property} />
</div>
))}
</div>
)}
{/* Position indicator */}
{showPositionIndicator && (
<p className="text-xs text-textTertiary text-center mt-6">
Exibindo {from}{to} de {result.total} imóveis
</p>
)}
{/* Bottom pagination */}
<div className="mt-4">
<Pagination
current={result.page}
total={result.pages}
onChange={handlePageChange}
ariaLabel="Paginação"
/>
</div>
</div>
) : !loading ? (
<EmptyStateWithSuggestions
hasFilters={hasActiveFilters(filters)}
suggestions={suggestions}
onClearAll={handleClear}
/>
) : null}
</>
)}
</div>
</div>
</div>
</main>
{/* Sidebar overlay */}
{/* Sidebar overlay — mobile only */}
{sidebarOpen && (
<div className="fixed inset-0 z-50">
<div

View file

@ -114,7 +114,10 @@ export default function PropertyDetailPage() {
<div>
<h1 className="text-2xl sm:text-3xl font-semibold text-textPrimary tracking-tight leading-tight mb-2 flex items-center gap-2">
{property.title}
<HeartButton propertyId={property.id} />
<HeartButton
propertyId={property.id}
snapshot={{ id: property.id, slug: property.slug, title: property.title, price: property.price, type: property.type, photo: property.photos?.[0]?.url ?? null, city: property.city?.name ?? null, bedrooms: property.bedrooms, area_m2: property.area_m2 }}
/>
</h1>
<div className="flex items-center flex-wrap gap-3">
{property.code && (

View file

@ -0,0 +1,78 @@
import { Link, Navigate } from 'react-router-dom';
import FavoritesCardsGrid from '../components/FavoritesCardsGrid';
import Navbar from '../components/Navbar';
import { useAuth } from '../contexts/AuthContext';
import { useFavorites } from '../contexts/FavoritesContext';
export default function PublicFavoritesPage() {
const { isAuthenticated, isLoading } = useAuth();
const { localEntries, favoriteIds } = useFavorites();
if (isLoading) {
return (
<div className="min-h-screen bg-canvas">
<Navbar />
<div className="max-w-4xl mx-auto px-4 pt-20 pb-10">
<div className="h-7 w-40 animate-pulse rounded-md bg-surface mb-6" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="rounded-xl border border-borderSubtle bg-panel h-64 animate-pulse" />
))}
</div>
</div>
</div>
);
}
// If authenticated, redirect to client area favorites
if (isAuthenticated) {
return <Navigate to="/area-do-cliente/favoritos" replace />;
}
return (
<div className="min-h-screen bg-canvas">
<Navbar />
<div className="max-w-4xl mx-auto px-4 pt-20 pb-10">
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-textPrimary">Meus Favoritos</h1>
<p className="text-sm text-textTertiary mt-1">
{favoriteIds.size} {favoriteIds.size === 1 ? 'imóvel salvo' : 'imóveis salvos'} localmente
</p>
</div>
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition shrink-0">
Voltar à listagem
</Link>
</div>
{/* Banner — incentivo ao cadastro */}
<div className="mb-6 rounded-xl border border-brand/30 bg-brand/5 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
<div>
<p className="text-sm font-medium text-textPrimary">Sincronize seus favoritos</p>
<p className="text-xs text-textTertiary mt-0.5">
Crie uma conta gratuita para acessar seus favoritos em qualquer dispositivo.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Link
to="/cadastro"
state={{ from: { pathname: '/area-do-cliente/favoritos' } }}
className="rounded-lg bg-brand px-4 py-2 text-xs font-semibold text-white hover:bg-accentHover transition"
>
Criar conta
</Link>
<Link
to="/login"
state={{ from: { pathname: '/area-do-cliente/favoritos' } }}
className="rounded-lg border border-borderSubtle bg-surface px-4 py-2 text-xs font-semibold text-textPrimary hover:bg-panel transition"
>
Entrar
</Link>
</div>
</div>
<FavoritesCardsGrid entries={localEntries} />
</div>
</div>
);
}

View file

@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import { getContactConfig, updateContactConfig, type ContactConfig } from '../../services/contactConfig'
const INITIAL: ContactConfig = {
address_street: '',
address_neighborhood_city: '',
address_zip: '',
phone: '',
email: '',
business_hours: '',
}
function nullToEmpty(cfg: ContactConfig): ContactConfig {
return {
address_street: cfg.address_street ?? '',
address_neighborhood_city: cfg.address_neighborhood_city ?? '',
address_zip: cfg.address_zip ?? '',
phone: cfg.phone ?? '',
email: cfg.email ?? '',
business_hours: cfg.business_hours ?? '',
}
}
export default function AdminContactConfigPage() {
const [form, setForm] = useState<ContactConfig>(INITIAL)
const [loadingData, setLoadingData] = useState(true)
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getContactConfig()
.then((data) => setForm(nullToEmpty(data)))
.catch(() => setError('Erro ao carregar configurações'))
.finally(() => setLoadingData(false))
}, [])
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
setSuccess(false)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
const updated = await updateContactConfig(form)
setForm(nullToEmpty(updated))
setSuccess(true)
} catch {
setError('Não foi possível salvar as configurações. Tente novamente.')
} finally {
setSaving(false)
}
}
if (loadingData) {
return (
<div className="p-6 md:p-8 flex justify-center py-16">
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="p-6 md:p-8 max-w-[680px]">
<div className="mb-6">
<h2 className="text-xl font-bold text-textPrimary">Configurações da Página de Contato</h2>
<p className="text-textTertiary text-sm mt-1">
Edite as informações exibidas na página pública <span className="font-medium text-textSecondary">/contato</span>.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Endereço */}
<fieldset className="border border-borderSubtle rounded-xl p-5 space-y-4">
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
Endereço
</legend>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Rua e número
</label>
<input
type="text"
name="address_street"
value={form.address_street ?? ''}
onChange={handleChange}
placeholder="Rua das Imobiliárias, 123"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Bairro, cidade e estado
</label>
<input
type="text"
name="address_neighborhood_city"
value={form.address_neighborhood_city ?? ''}
onChange={handleChange}
placeholder="Centro — São Paulo, SP"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
CEP
</label>
<input
type="text"
name="address_zip"
value={form.address_zip ?? ''}
onChange={handleChange}
placeholder="CEP 01000-000"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
</fieldset>
{/* Contato */}
<fieldset className="border border-borderSubtle rounded-xl p-5 space-y-4">
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
Contato
</legend>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
Telefone
</label>
<input
type="text"
name="phone"
value={form.phone ?? ''}
onChange={handleChange}
placeholder="(11) 99999-0000"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
E-mail
</label>
<input
type="email"
name="email"
value={form.email ?? ''}
onChange={handleChange}
placeholder="contato@imobiliariahub.com.br"
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
</div>
</fieldset>
{/* Horário */}
<fieldset className="border border-borderSubtle rounded-xl p-5">
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
Horário de atendimento
</legend>
<div className="mt-3">
<label className="block text-xs font-medium text-textSecondary mb-1">
Texto livre (use Enter para cada linha)
</label>
<textarea
name="business_hours"
value={form.business_hours ?? ''}
onChange={handleChange}
rows={4}
placeholder={"Segunda a sexta: 9h às 18h\nSábados: 9h às 13h\nDomingos e feriados: fechado"}
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 resize-none"
/>
<p className="text-xs text-textTertiary mt-1">
Cada linha será exibida separada na página pública.
</p>
</div>
</fieldset>
{error && (
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
{error}
</div>
)}
{success && (
<div className="bg-green-500/10 text-green-400 text-sm rounded-lg px-4 py-3 flex items-center gap-2">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Configurações salvas com sucesso!
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={saving}
className="bg-brand hover:bg-accentHover text-white font-medium text-sm rounded-lg px-6 py-2.5 transition-colors disabled:opacity-60"
>
{saving ? 'Salvando…' : 'Salvar alterações'}
</button>
</div>
</form>
</div>
)
}

View file

@ -0,0 +1,382 @@
import { useEffect, useState } from 'react'
import {
getHomepageConfig,
uploadHomepageHeroImage,
updateHomepageHeroImages,
} from '../../services/homepage'
type FormState = {
hero_image_url: string
hero_image_light_url: string
hero_image_dark_url: string
}
const INITIAL: FormState = {
hero_image_url: '',
hero_image_light_url: '',
hero_image_dark_url: '',
}
const DEFAULT_HERO_IMAGE_LIGHT_URL =
'https://images.unsplash.com/photo-1512918728675-ed5a9ecdebfd?auto=format&fit=crop&w=1920&q=80'
const DEFAULT_HERO_IMAGE_DARK_URL =
'https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?auto=format&fit=crop&w=1920&q=80'
function nullToEmpty(v?: string | null): string {
return v ?? ''
}
function buildDownloadName(url: string, fallbackName: string): string {
try {
const parsed = new URL(url)
const pathname = parsed.pathname.split('/').filter(Boolean)
const lastSegment = pathname[pathname.length - 1]
if (!lastSegment) return fallbackName
return decodeURIComponent(lastSegment)
} catch {
return fallbackName
}
}
export default function AdminHomepageConfigPage() {
const [form, setForm] = useState<FormState>(INITIAL)
const [loadingData, setLoadingData] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingField, setUploadingField] = useState<keyof FormState | null>(null)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getHomepageConfig()
.then((data) => {
setForm({
hero_image_url: nullToEmpty(data.hero_image_url),
hero_image_light_url: nullToEmpty(data.hero_image_light_url),
hero_image_dark_url: nullToEmpty(data.hero_image_dark_url),
})
})
.catch(() => setError('Erro ao carregar configurações da home'))
.finally(() => setLoadingData(false))
}, [])
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
setSuccess(false)
}
async function handleUpload(
e: React.ChangeEvent<HTMLInputElement>,
field: keyof FormState,
) {
const file = e.target.files?.[0]
if (!file) return
setUploadingField(field)
setError(null)
setSuccess(false)
try {
const uploaded = await uploadHomepageHeroImage(file)
setForm((prev) => ({ ...prev, [field]: uploaded.url }))
} catch {
setError('Não foi possível enviar a imagem. Tente novamente.')
} finally {
setUploadingField(null)
e.target.value = ''
}
}
function renderPreviewCard(label: string, url: string | null, fallbackHint?: string) {
return (
<div className="rounded-xl border border-borderSubtle bg-panel p-3">
<p className="text-xs font-medium text-textSecondary mb-2">{label}</p>
<div className="h-28 w-full overflow-hidden rounded-lg bg-surface border border-borderSubtle">
{url ? (
<img src={url} alt={label} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center text-xs text-textTertiary">
Sem imagem definida
</div>
)}
</div>
{fallbackHint && (
<p className="mt-2 text-[11px] text-textTertiary">{fallbackHint}</p>
)}
</div>
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
const updated = await updateHomepageHeroImages({
hero_image_url: form.hero_image_url || null,
hero_image_light_url: form.hero_image_light_url || null,
hero_image_dark_url: form.hero_image_dark_url || null,
})
setForm({
hero_image_url: nullToEmpty(updated.hero_image_url),
hero_image_light_url: nullToEmpty(updated.hero_image_light_url),
hero_image_dark_url: nullToEmpty(updated.hero_image_dark_url),
})
setSuccess(true)
} catch {
setError('Não foi possível salvar as imagens da home. Tente novamente.')
} finally {
setSaving(false)
}
}
if (loadingData) {
return (
<div className="p-6 md:p-8 flex justify-center py-16">
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="p-6 md:p-8 max-w-[760px]">
<div className="mb-6">
<h2 className="text-xl font-bold text-textPrimary">Configuração da Home</h2>
<p className="text-textTertiary text-sm mt-1">
Defina imagens de fundo separadas para os temas claro e escuro na seção hero da página inicial.
</p>
</div>
<div className="mb-6 grid grid-cols-1 sm:grid-cols-3 gap-3">
{renderPreviewCard(
'Preview fallback (legado)',
form.hero_image_url || null,
'Usada quando o tema específico não estiver configurado.',
)}
{renderPreviewCard(
'Preview tema light',
form.hero_image_light_url || form.hero_image_url || null,
form.hero_image_light_url
? 'Imagem light específica definida.'
: 'Sem light específica: usando fallback legado.',
)}
{renderPreviewCard(
'Preview tema dark',
form.hero_image_dark_url || form.hero_image_url || null,
form.hero_image_dark_url
? 'Imagem dark específica definida.'
: 'Sem dark específica: usando fallback legado.',
)}
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<fieldset className="border border-borderSubtle rounded-xl p-5 space-y-4">
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
Hero Background
</legend>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
URL fallback (legado)
</label>
<input
type="url"
name="hero_image_url"
value={form.hero_image_url}
onChange={handleChange}
placeholder="https://..."
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
<p className="text-xs text-textTertiary mt-1">
Usada como fallback quando a imagem específica do tema não estiver preenchida.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
{uploadingField === 'hero_image_url' ? 'Enviando…' : 'Enviar nova imagem fallback'}
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleUpload(e, 'hero_image_url')}
/>
</label>
</div>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
URL imagem tema claro (light)
</label>
<input
type="url"
name="hero_image_light_url"
value={form.hero_image_light_url}
onChange={handleChange}
placeholder="https://..."
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
<div className="mt-2 flex flex-wrap gap-2">
{form.hero_image_light_url && (
<a
href={form.hero_image_light_url}
download={buildDownloadName(form.hero_image_light_url, 'home-hero-light.jpg')}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Baixar imagem light atual
</a>
)}
{!!form.hero_image_url && form.hero_image_url !== form.hero_image_light_url && (
<button
type="button"
onClick={() => {
setForm((prev) => ({ ...prev, hero_image_light_url: prev.hero_image_url }))
setSuccess(false)
}}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Usar fallback como light
</button>
)}
{!!form.hero_image_dark_url && form.hero_image_dark_url !== form.hero_image_light_url && (
<button
type="button"
onClick={() => {
setForm((prev) => ({ ...prev, hero_image_light_url: prev.hero_image_dark_url }))
setSuccess(false)
}}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Usar imagem dark como light
</button>
)}
{!form.hero_image_light_url && (
<button
type="button"
onClick={() => {
setForm((prev) => ({
...prev,
hero_image_light_url: DEFAULT_HERO_IMAGE_LIGHT_URL,
}))
setSuccess(false)
}}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Preencher light com fallback do sistema
</button>
)}
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
{uploadingField === 'hero_image_light_url' ? 'Enviando…' : 'Criar nova imagem light (upload)'}
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleUpload(e, 'hero_image_light_url')}
/>
</label>
</div>
</div>
<div>
<label className="block text-xs font-medium text-textSecondary mb-1">
URL imagem tema escuro (dark)
</label>
<input
type="url"
name="hero_image_dark_url"
value={form.hero_image_dark_url}
onChange={handleChange}
placeholder="https://..."
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
/>
{form.hero_image_dark_url && (
<div className="mt-2 flex flex-wrap gap-2">
<a
href={form.hero_image_dark_url}
download={buildDownloadName(form.hero_image_dark_url, 'home-hero-dark.jpg')}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Baixar imagem dark atual
</a>
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
{uploadingField === 'hero_image_dark_url' ? 'Enviando…' : 'Criar nova imagem dark (upload)'}
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleUpload(e, 'hero_image_dark_url')}
/>
</label>
{!form.hero_image_dark_url && (
<button
type="button"
onClick={() => {
setForm((prev) => ({
...prev,
hero_image_dark_url: DEFAULT_HERO_IMAGE_DARK_URL,
}))
setSuccess(false)
}}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Preencher dark com fallback do sistema
</button>
)}
</div>
)}
{!form.hero_image_dark_url && (
<div className="mt-2 flex flex-wrap gap-2">
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
{uploadingField === 'hero_image_dark_url' ? 'Enviando…' : 'Criar nova imagem dark (upload)'}
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleUpload(e, 'hero_image_dark_url')}
/>
</label>
<button
type="button"
onClick={() => {
setForm((prev) => ({
...prev,
hero_image_dark_url: DEFAULT_HERO_IMAGE_DARK_URL,
}))
setSuccess(false)
}}
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
>
Preencher dark com fallback do sistema
</button>
</div>
)}
</div>
</fieldset>
{error && (
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
{error}
</div>
)}
{success && (
<div className="bg-green-500/10 text-green-400 text-sm rounded-lg px-4 py-3 flex items-center gap-2">
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Configuração da home salva com sucesso!
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={saving}
className="bg-brand hover:bg-accentHover text-white font-medium text-sm rounded-lg px-6 py-2.5 transition-colors disabled:opacity-60"
>
{saving ? 'Salvando…' : 'Salvar alterações'}
</button>
</div>
</form>
</div>
)
}

View file

@ -0,0 +1,231 @@
import { useEffect, useState } from 'react'
import api from '../../services/api'
interface JobApplication {
id: number
name: string
email: string
phone: string | null
role_interest: string
message: string
file_name: string | null
status: string
created_at: string
}
interface PaginatedJobs {
items: JobApplication[]
total: number
page: number
per_page: number
pages: number
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Pendente',
reviewed: 'Revisado',
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-amber-500/10 text-amber-400',
reviewed: 'bg-emerald-500/10 text-emerald-400',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
export default function AdminJobsPage() {
const [items, setItems] = useState<JobApplication[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pages, setPages] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<number | null>(null)
function fetchJobs(p = 1) {
setLoading(true)
setError(null)
api.get<PaginatedJobs>('/admin/jobs', { params: { page: p, per_page: 20 } })
.then((res) => {
setItems(res.data.items)
setTotal(res.data.total)
setPage(res.data.page)
setPages(res.data.pages)
})
.catch(() => setError('Erro ao carregar candidaturas'))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchJobs(1)
}, [])
return (
<div className="p-6 md:p-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div>
<h2 className="text-xl font-bold text-textPrimary">Candidaturas</h2>
<p className="text-textTertiary text-sm mt-0.5">
{total} candidatura{total !== 1 ? 's' : ''} recebida{total !== 1 ? 's' : ''}
</p>
</div>
</div>
{error && (
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3 mb-4">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
</div>
) : items.length === 0 ? (
<div className="text-center py-16 text-textTertiary text-sm">
Nenhuma candidatura recebida ainda.
</div>
) : (
<>
<div className="overflow-x-auto rounded-xl border border-borderSubtle">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-borderSubtle bg-surface">
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">Nome</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden md:table-cell">E-mail</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden lg:table-cell">Telefone</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden md:table-cell">Cargo</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden xl:table-cell">Currículo</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider whitespace-nowrap">Data</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{items.map((item, i) => {
const statusColor = STATUS_COLORS[item.status] ?? 'bg-gray-500/10 text-gray-400'
const statusLabel = STATUS_LABELS[item.status] ?? item.status
const isOpen = expanded === item.id
return (
<>
<tr
key={item.id}
className={`border-b border-borderSubtle ${isOpen ? '' : 'last:border-0'} ${i % 2 === 0 ? 'bg-canvas' : 'bg-surface'}`}
>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}`}>
{statusLabel}
</span>
</td>
<td className="px-4 py-3 text-textPrimary font-medium whitespace-nowrap">
{item.name}
</td>
<td className="px-4 py-3 text-textSecondary hidden md:table-cell">
<a href={`mailto:${item.email}`} className="hover:text-textPrimary transition-colors">
{item.email}
</a>
</td>
<td className="px-4 py-3 text-textSecondary hidden lg:table-cell whitespace-nowrap">
{item.phone
? <a href={`tel:${item.phone}`} className="hover:text-textPrimary transition-colors">{item.phone}</a>
: '—'
}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-[#5e6ad2]/10 text-[#5e6ad2]">
{item.role_interest}
</span>
</td>
<td className="px-4 py-3 text-textTertiary text-xs hidden xl:table-cell max-w-[160px]">
{item.file_name
? <span className="flex items-center gap-1 truncate" title={item.file_name}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg>
{item.file_name}
</span>
: '—'
}
</td>
<td className="px-4 py-3 text-textTertiary text-xs whitespace-nowrap">
{formatDate(item.created_at)}
</td>
<td className="px-4 py-3">
<button
onClick={() => setExpanded(isOpen ? null : item.id)}
aria-label={isOpen ? 'Recolher' : 'Ver apresentação'}
className="text-textQuaternary hover:text-textSecondary transition-colors"
>
<svg className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</td>
</tr>
{/* Linha expandida com a mensagem */}
{isOpen && (
<tr key={`${item.id}-expanded`} className={`border-b border-borderSubtle ${i % 2 === 0 ? 'bg-canvas' : 'bg-surface'}`}>
<td colSpan={8} className="px-6 pb-5 pt-2">
<p className="text-xs font-medium text-textTertiary uppercase tracking-wider mb-2">Apresentação</p>
<p className="text-sm text-textSecondary leading-relaxed whitespace-pre-wrap bg-panel rounded-lg p-4 border border-borderSubtle">
{item.message}
</p>
{/* Mobile: campos ocultos na tabela */}
<div className="flex flex-wrap gap-4 mt-3 md:hidden">
<div>
<p className="text-xs text-textQuaternary mb-0.5">E-mail</p>
<a href={`mailto:${item.email}`} className="text-sm text-textSecondary hover:text-textPrimary">{item.email}</a>
</div>
<div>
<p className="text-xs text-textQuaternary mb-0.5">Cargo</p>
<p className="text-sm text-textSecondary">{item.role_interest}</p>
</div>
{item.file_name && (
<div>
<p className="text-xs text-textQuaternary mb-0.5">Currículo</p>
<p className="text-sm text-textSecondary">{item.file_name}</p>
</div>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
{/* Paginação */}
{pages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm text-textTertiary">
<span>Página {page} de {pages}</span>
<div className="flex gap-2">
<button
disabled={page <= 1}
onClick={() => { const p = page - 1; setPage(p); fetchJobs(p) }}
className="px-3 py-1.5 rounded-lg border border-borderSubtle hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Anterior
</button>
<button
disabled={page >= pages}
onClick={() => { const p = page + 1; setPage(p); fetchJobs(p) }}
className="px-3 py-1.5 rounded-lg border border-borderSubtle hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Próxima
</button>
</div>
</div>
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,237 @@
import { useEffect, useState } from 'react'
import api from '../../services/api'
interface Lead {
id: number
property_id: string | null
name: string
email: string
phone: string | null
message: string
source: string | null
source_detail: string | null
created_at: string
}
interface PaginatedLeads {
items: Lead[]
total: number
page: number
per_page: number
pages: number
}
const SOURCE_LABELS: Record<string, string> = {
contato: 'Contato',
imovel: 'Imóvel',
cadastro_residencia: 'Cadastro',
}
const SOURCE_COLORS: Record<string, string> = {
contato: 'bg-blue-500/10 text-blue-400',
imovel: 'bg-purple-500/10 text-purple-400',
cadastro_residencia: 'bg-emerald-500/10 text-emerald-400',
}
const FILTERS = [
{ value: '', label: 'Todos' },
{ value: 'contato', label: 'Contato' },
{ value: 'imovel', label: 'Imóvel' },
{ value: 'cadastro_residencia', label: 'Cadastro' },
]
export default function AdminLeadsPage() {
const [leads, setLeads] = useState<Lead[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pages, setPages] = useState(1)
const [source, setSource] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
function fetchLeads(p = 1, src = source) {
setLoading(true)
setError(null)
const params: Record<string, string | number> = { page: p, per_page: 20 }
if (src) params.source = src
api.get<PaginatedLeads>('/admin/leads', { params })
.then((res) => {
setLeads(res.data.items)
setTotal(res.data.total)
setPage(res.data.page)
setPages(res.data.pages)
})
.catch(() => setError('Erro ao carregar leads'))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchLeads(1, source)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [source])
function handleFilterChange(val: string) {
setSource(val)
setPage(1)
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div className="p-6 md:p-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div>
<h2 className="text-xl font-bold text-textPrimary">Central de Leads</h2>
<p className="text-textTertiary text-sm mt-0.5">
{total} lead{total !== 1 ? 's' : ''} encontrado{total !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Filtros por origem */}
<div className="flex gap-2 flex-wrap mb-6">
{FILTERS.map((f) => (
<button
key={f.value}
onClick={() => handleFilterChange(f.value)}
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-colors border ${source === f.value
? 'bg-brand text-white border-brand'
: 'bg-transparent text-textSecondary border-borderSubtle hover:border-brand/40'
}`}
>
{f.label}
</button>
))}
</div>
{error && (
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3 mb-4">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
</div>
) : leads.length === 0 ? (
<div className="text-center py-16 text-textTertiary text-sm">
Nenhum lead encontrado.
</div>
) : (
<>
<div className="overflow-x-auto rounded-xl border border-borderSubtle">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-borderSubtle bg-surface">
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">
Origem
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">
Nome
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden md:table-cell">
E-mail
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden lg:table-cell">
Telefone
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden xl:table-cell">
Mensagem
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden lg:table-cell">
Detalhe
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider whitespace-nowrap">
Data
</th>
</tr>
</thead>
<tbody>
{leads.map((lead, i) => {
const src = lead.source ?? 'contato'
const colorClass = SOURCE_COLORS[src] ?? 'bg-gray-500/10 text-gray-400'
const srcLabel = SOURCE_LABELS[src] ?? src
return (
<tr
key={lead.id}
className={`border-b border-borderSubtle last:border-0 ${i % 2 === 0 ? 'bg-canvas' : 'bg-surface'
}`}
>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}
>
{srcLabel}
</span>
</td>
<td className="px-4 py-3 text-textPrimary font-medium">
{lead.name}
</td>
<td className="px-4 py-3 text-textSecondary hidden md:table-cell">
{lead.email}
</td>
<td className="px-4 py-3 text-textSecondary hidden lg:table-cell">
{lead.phone ?? '—'}
</td>
<td className="px-4 py-3 text-textSecondary hidden xl:table-cell max-w-[240px]">
<span
className="block truncate"
title={lead.message}
>
{lead.message}
</span>
</td>
<td className="px-4 py-3 text-textTertiary hidden lg:table-cell max-w-[160px]">
<span
className="block truncate text-xs"
title={lead.source_detail ?? ''}
>
{lead.source_detail ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-textTertiary text-xs whitespace-nowrap">
{formatDate(lead.created_at)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Paginação */}
{pages > 1 && (
<div className="flex justify-center gap-2 mt-6">
<button
disabled={page <= 1}
onClick={() => fetchLeads(page - 1)}
className="px-3 py-1.5 text-xs rounded-lg border border-borderSubtle text-textSecondary hover:border-brand/40 disabled:opacity-40 disabled:cursor-not-allowed"
>
Anterior
</button>
<span className="px-3 py-1.5 text-xs text-textSecondary">
{page} / {pages}
</span>
<button
disabled={page >= pages}
onClick={() => fetchLeads(page + 1)}
className="px-3 py-1.5 text-xs rounded-lg border border-borderSubtle text-textSecondary hover:border-brand/40 disabled:opacity-40 disabled:cursor-not-allowed"
>
Próxima
</button>
</div>
)}
</>
)}
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,100 +0,0 @@
import { useEffect, useState } from 'react';
import { getBoletos } from '../../services/clientArea';
import type { Boleto } from '../../types/clientArea';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: 'Pendente', color: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
paid: { label: 'Pago', color: 'bg-green-500/10 text-green-400 border-green-500/20' },
overdue: { label: 'Vencido', color: 'bg-red-500/10 text-red-400 border-red-500/20' },
};
export default function BoletosPage() {
const [boletos, setBoletos] = useState<Boleto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getBoletos()
.then(setBoletos)
.catch(() => setBoletos([]))
.finally(() => setLoading(false));
}, []);
function formatCurrency(amount: number | string) {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(num);
}
function formatDate(d: string) {
return new Intl.DateTimeFormat('pt-BR').format(new Date(d + 'T00:00:00'));
}
if (loading) {
return (
<div className="p-6 space-y-3">
<div className="h-7 w-40 animate-pulse rounded-md bg-white/[0.06]" />
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-xl bg-panel border border-borderSubtle" />
))}
</div>
);
}
return (
<div className="p-6 max-w-4xl">
<h1 className="text-xl font-semibold text-textPrimary mb-6">Meus Boletos</h1>
{boletos.length === 0 ? (
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
<p className="text-textTertiary">Nenhum boleto disponível</p>
</div>
) : (
<div className="rounded-xl border border-borderSubtle bg-panel overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-borderSubtle">
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Descrição</th>
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Imóvel</th>
<th className="px-4 py-3 text-right text-xs font-medium text-textTertiary uppercase tracking-wide">Valor</th>
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Vencimento</th>
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Ação</th>
</tr>
</thead>
<tbody>
{boletos.map(boleto => {
const status = STATUS_LABELS[boleto.status] ?? { label: boleto.status, color: 'bg-white/10 text-white/60 border-white/10' };
return (
<tr key={boleto.id} className="border-b border-borderSubtle hover:bg-surface transition">
<td className="px-4 py-3 text-textPrimary">{boleto.description}</td>
<td className="px-4 py-3 text-textSecondary text-xs">{boleto.property?.title ?? '—'}</td>
<td className="px-4 py-3 text-right text-textPrimary font-medium">{formatCurrency(boleto.amount)}</td>
<td className="px-4 py-3 text-textSecondary">{formatDate(boleto.due_date)}</td>
<td className="px-4 py-3">
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${status.color}`}>
{status.label}
</span>
</td>
<td className="px-4 py-3">
{boleto.url ? (
<a
href={boleto.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:text-accentHover transition"
>
Ver boleto
</a>
) : (
<span className="text-xs text-textQuaternary"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -1,53 +0,0 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { getBoletos, getFavorites, getVisits } from '../../services/clientArea';
export default function ClientDashboardPage() {
const { user } = useAuth();
const [counts, setCounts] = useState({ favorites: 0, visits: 0, boletos: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([getFavorites(), getVisits(), getBoletos()])
.then(([favs, visits, boletos]) => {
setCounts({
favorites: Array.isArray(favs) ? favs.length : 0,
visits: visits.filter(v => v.status === 'pending' || v.status === 'confirmed').length,
boletos: boletos.filter(b => b.status === 'pending').length,
});
})
.catch(() => { })
.finally(() => setLoading(false));
}, []);
const cards = [
{ label: 'Favoritos', value: counts.favorites, to: '/area-do-cliente/favoritos', color: 'text-red-400' },
{ label: 'Visitas ativas', value: counts.visits, to: '/area-do-cliente/visitas', color: 'text-blue-400' },
{ label: 'Boletos pendentes', value: counts.boletos, to: '/area-do-cliente/boletos', color: 'text-yellow-400' },
];
return (
<div className="p-6 max-w-4xl">
<h1 className="text-xl font-semibold text-textPrimary mb-1">Olá, {user?.name?.split(' ')[0]}</h1>
<p className="text-sm text-textTertiary mb-8">Bem-vindo à sua área do cliente</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{cards.map(card => (
<Link
key={card.to}
to={card.to}
className="group rounded-xl border border-borderSubtle bg-panel p-5 hover:border-borderStandard transition"
>
<p className="text-sm text-textSecondary mb-2">{card.label}</p>
{loading ? (
<div className="h-8 w-16 animate-pulse rounded-md bg-surface" />
) : (
<p className={`text-3xl font-semibold ${card.color}`}>{card.value}</p>
)}
</Link>
))}
</div>
</div>
);
}

View file

@ -20,9 +20,22 @@ export default function ComparisonPage() {
return (
<div className="p-6">
<h1 className="text-xl font-semibold text-textPrimary mb-6">Comparar Imóveis</h1>
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
<p className="text-textTertiary mb-4">Nenhum imóvel selecionado para comparação</p>
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center max-w-sm mx-auto">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
strokeWidth={1.5} stroke="currentColor" className="size-6 text-textTertiary">
<path strokeLinecap="round" strokeLinejoin="round"
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 15.95M5.25 4.97l-2.62 15.95m0 0a48.959 48.959 0 0 0 3.32.65M5.63 20.92a48.958 48.958 0 0 0 3.32-.65" />
</svg>
</div>
<p className="text-sm font-medium text-textPrimary mb-2">Compare imóveis lado a lado</p>
<p className="text-xs text-textTertiary mb-4">
Para adicionar um imóvel à comparação, clique no ícone nos cards de imóveis. Você pode comparar até 3 imóveis simultaneamente.
</p>
<Link
to="/imoveis"
className="inline-block rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accentHover transition"
>
Explorar imóveis
</Link>
</div>

View file

@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import HeartButton from '../../components/HeartButton';
import FavoritesCardsGrid, { type FavoriteCardEntry } from '../../components/FavoritesCardsGrid';
import { useFavorites } from '../../contexts/FavoritesContext';
import { getFavorites } from '../../services/clientArea';
import type { SavedProperty } from '../../types/clientArea';
export default function FavoritesPage() {
const [favorites, setFavorites] = useState<any[]>([]);
const [favorites, setFavorites] = useState<SavedProperty[]>([]);
const [loading, setLoading] = useState(true);
const { favoriteIds, isLoading: favoritesLoading } = useFavorites();
useEffect(() => {
getFavorites()
@ -14,13 +17,38 @@ export default function FavoritesPage() {
.finally(() => setLoading(false));
}, []);
if (loading) {
const mappedEntries: FavoriteCardEntry[] = favorites
.map((item) => {
const prop = item.property as (SavedProperty['property'] & {
listing_type?: 'venda' | 'aluguel';
bedrooms?: number;
area_m2?: number;
}) | null;
if (!item.property_id || !prop) return null;
return {
id: item.property_id,
slug: prop.slug ?? '',
title: prop.title ?? 'Imóvel',
price: prop.price ?? '',
type: prop.listing_type === 'aluguel' ? 'aluguel' : 'venda',
photo: prop.cover_photo_url,
city: [prop.neighborhood, prop.city].filter(Boolean).join(', ') || null,
bedrooms: Number(prop.bedrooms ?? 0),
area_m2: Number(prop.area_m2 ?? 0),
};
})
.filter((entry): entry is FavoriteCardEntry => !!entry)
.filter(entry => favoriteIds.has(entry.id));
if (loading || favoritesLoading) {
return (
<div className="p-6">
<div className="h-7 w-40 animate-pulse rounded-md bg-surface mb-6" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="rounded-xl border border-borderSubtle bg-panel h-48 animate-pulse" />
<div key={i} className="rounded-xl border border-borderSubtle bg-panel h-64 animate-pulse" />
))}
</div>
</div>
@ -28,37 +56,35 @@ export default function FavoritesPage() {
}
return (
<div className="p-6 max-w-5xl">
<h1 className="text-xl font-semibold text-textPrimary mb-6">Meus Favoritos</h1>
<div className="mx-auto max-w-4xl px-4 pt-6 pb-10">
<div className="mb-8 flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-textPrimary">Meus Favoritos</h1>
<p className="text-sm text-textTertiary mt-1">
{mappedEntries.length} {mappedEntries.length === 1 ? 'imóvel salvo' : 'imóveis salvos'} na sua conta
</p>
</div>
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition shrink-0">
Voltar à listagem
</Link>
</div>
{favorites.length === 0 ? (
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
<p className="text-textTertiary mb-4">Nenhum favorito ainda</p>
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
Explorar imóveis
</Link>
<div className="mb-6 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
<div>
<p className="text-sm font-medium text-textPrimary">Favoritos sincronizados</p>
<p className="text-xs text-textTertiary mt-0.5">
Seus favoritos ficam salvos na conta e disponíveis em qualquer dispositivo.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{favorites.map((item: any) => {
const prop = item.property || item;
const propertyId = item.property_id || prop?.id;
return (
<div key={item.id || propertyId} className="relative rounded-xl border border-borderSubtle bg-panel p-4 hover:border-borderStandard transition">
{propertyId && (
<div className="absolute top-3 right-3">
<HeartButton propertyId={propertyId} />
</div>
)}
<Link to={prop?.slug ? `/imoveis/${prop.slug}` : '#'} className="block">
<p className="text-sm font-medium text-textPrimary pr-8 line-clamp-2">{prop?.title || 'Imóvel'}</p>
<p className="mt-1 text-xs text-textTertiary">Ver detalhes </p>
</Link>
</div>
);
})}
</div>
)}
<Link
to="/area-do-cliente/conta"
className="rounded-lg border border-borderSubtle bg-surface px-4 py-2 text-xs font-semibold text-textPrimary hover:bg-panel transition shrink-0"
>
Ver minha conta
</Link>
</div>
<FavoritesCardsGrid entries={mappedEntries} />
</div>
);
}

View file

@ -0,0 +1,153 @@
import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { changePassword, updateProfile } from '../../services/clientArea';
export default function ProfilePage() {
const { user, updateUser } = useAuth();
// — Form de perfil —
const [name, setName] = useState(user?.name ?? '');
const [nameError, setNameError] = useState('');
const [nameSaving, setNameSaving] = useState(false);
const [nameSuccess, setNameSuccess] = useState(false);
// — Form de senha —
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordSuccess, setPasswordSuccess] = useState(false);
async function handleSaveName(e: React.FormEvent) {
e.preventDefault();
setNameError('');
setNameSuccess(false);
if (!name.trim()) {
setNameError('O nome não pode ser vazio.');
return;
}
setNameSaving(true);
try {
const updated = await updateProfile({ name: name.trim() });
updateUser({ name: updated.name });
setNameSuccess(true);
} catch {
setNameError('Erro ao salvar. Tente novamente.');
} finally {
setNameSaving(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordError('');
setPasswordSuccess(false);
if (newPassword.length < 8) {
setPasswordError('A nova senha deve ter pelo menos 8 caracteres.');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('As senhas não coincidem.');
return;
}
setPasswordSaving(true);
try {
await changePassword({ current_password: currentPassword, new_password: newPassword });
setPasswordSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'Erro ao alterar senha.';
setPasswordError(msg);
} finally {
setPasswordSaving(false);
}
}
return (
<div className="p-6 max-w-lg space-y-8">
<h1 className="text-xl font-semibold text-textPrimary">Minha conta</h1>
{/* Formulário: dados pessoais */}
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
<h2 className="text-sm font-semibold text-textPrimary">Dados pessoais</h2>
<form onSubmit={handleSaveName} className="space-y-4">
<div>
<label className="block text-xs text-textSecondary mb-1">Nome</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">E-mail</label>
<input
type="email"
value={user?.email ?? ''}
readOnly
className="w-full rounded-lg border border-borderSubtle bg-surface px-3 py-2 text-sm text-textTertiary cursor-not-allowed"
/>
</div>
{nameError && <p className="text-xs text-red-400">{nameError}</p>}
{nameSuccess && <p className="text-xs text-green-400">Nome atualizado com sucesso!</p>}
<button
type="submit"
disabled={nameSaving}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accentHover disabled:opacity-50 transition"
>
{nameSaving ? 'Salvando…' : 'Salvar alterações'}
</button>
</form>
</section>
{/* Formulário: trocar senha */}
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
<h2 className="text-sm font-semibold text-textPrimary">Alterar senha</h2>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-xs text-textSecondary mb-1">Senha atual</label>
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">Nova senha</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">Confirmar nova senha</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
{passwordError && <p className="text-xs text-red-400">{passwordError}</p>}
{passwordSuccess && <p className="text-xs text-green-400">Senha alterada com sucesso!</p>}
<button
type="submit"
disabled={passwordSaving}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accentHover disabled:opacity-50 transition"
>
{passwordSaving ? 'Salvando…' : 'Alterar senha'}
</button>
</form>
</section>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getVisits } from '../../services/clientArea';
import { cancelVisit, getVisits } from '../../services/clientArea';
import type { VisitRequest } from '../../types/clientArea';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
@ -13,6 +13,8 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
export default function VisitsPage() {
const [visits, setVisits] = useState<VisitRequest[]>([]);
const [loading, setLoading] = useState(true);
const [cancelling, setCancelling] = useState<string | null>(null);
const [cancelError, setCancelError] = useState<string | null>(null);
useEffect(() => {
getVisits()
@ -21,6 +23,24 @@ export default function VisitsPage() {
.finally(() => setLoading(false));
}, []);
async function handleCancel(visitId: string) {
if (!window.confirm('Confirmar cancelamento desta visita?')) return;
setCancelling(visitId);
setCancelError(null);
setVisits(prev =>
prev.map(v => (v.id === visitId ? { ...v, status: 'cancelled' as const } : v))
);
try {
const updated = await cancelVisit(visitId);
setVisits(prev => prev.map(v => (v.id === visitId ? updated : v)));
} catch {
setCancelError('Não foi possível cancelar. Tente novamente.');
getVisits().then(setVisits).catch(() => {});
} finally {
setCancelling(null);
}
}
const formatDate = (d: string | null) => {
if (!d) return '—';
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(d));
@ -79,10 +99,22 @@ export default function VisitsPage() {
<span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-xs font-medium ${status.color}`}>
{status.label}
</span>
{visit.status === 'pending' && (
<button
onClick={() => handleCancel(visit.id)}
disabled={cancelling === visit.id}
className="mt-3 rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition"
>
{cancelling === visit.id ? 'Cancelando…' : 'Cancelar visita'}
</button>
)}
</div>
</div>
);
})}
{cancelError && (
<p className="mt-2 text-xs text-red-400">{cancelError}</p>
)}
</div>
)}
</div>

View file

@ -1,4 +1,4 @@
import type { Boleto, SavedProperty, VisitRequest } from '../types/clientArea';
import type { Boleto, ChangePasswordPayload, SavedProperty, UpdateProfilePayload, UpdateProfileResponse, VisitRequest } from '../types/clientArea';
import api from './api';
export async function getFavorites(): Promise<SavedProperty[]> {
@ -23,3 +23,18 @@ export async function getBoletos(): Promise<Boleto[]> {
const response = await api.get<Boleto[]>('/me/boletos');
return response.data;
}
export async function updateProfile(data: UpdateProfilePayload): Promise<UpdateProfileResponse> {
const response = await api.patch<UpdateProfileResponse>('/me/profile', data);
return response.data;
}
export async function changePassword(data: ChangePasswordPayload): Promise<void> {
await api.patch('/me/password', data);
}
export async function cancelVisit(visitId: string): Promise<VisitRequest> {
const response = await api.patch<VisitRequest>(`/me/visits/${visitId}/cancel`);
return response.data;
}

View file

@ -0,0 +1,20 @@
import { api } from './api'
export interface ContactConfig {
address_street: string | null
address_neighborhood_city: string | null
address_zip: string | null
phone: string | null
email: string | null
business_hours: string | null
}
export async function getContactConfig(): Promise<ContactConfig> {
const res = await api.get<ContactConfig>('/contact-config')
return res.data
}
export async function updateContactConfig(data: ContactConfig): Promise<ContactConfig> {
const res = await api.put<ContactConfig>('/admin/contact-config', data)
return res.data
}

View file

@ -1,7 +1,32 @@
import type { HomepageConfig } from '../types/homepage'
import type { HomepageConfig, HomepageHeroImagesPayload } from '../types/homepage'
import { api } from './api'
interface UploadPhotoResponse {
url: string
filename: string
}
export async function getHomepageConfig(): Promise<HomepageConfig> {
const response = await api.get<HomepageConfig>('/homepage-config')
return response.data
}
export async function updateHomepageHeroImages(
payload: HomepageHeroImagesPayload,
): Promise<HomepageConfig> {
const response = await api.put<HomepageConfig>('/admin/homepage-config', payload)
return response.data
}
export async function uploadHomepageHeroImage(file: File): Promise<UploadPhotoResponse> {
const formData = new FormData()
formData.append('file', file)
const response = await api.post<UploadPhotoResponse>('/admin/upload/photo', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}

View file

@ -0,0 +1,14 @@
import { api } from './api'
export interface JobApplicationPayload {
name: string
email: string
phone?: string
role_interest: string
message: string
file_name?: string
}
export async function submitJobApplication(data: JobApplicationPayload): Promise<void> {
await api.post('/jobs/apply', data)
}

View file

@ -79,3 +79,10 @@ export async function submitContactForm(
return response.data
}
export async function submitGeneralContact(
data: ContactFormData
): Promise<{ id: number; message: string }> {
const response = await api.post<{ id: number; message: string }>('/contact', data)
return response.data
}

View file

@ -18,13 +18,35 @@ export interface Boleto {
created_at: string;
}
export interface PropertyCard {
id: string;
title: string;
slug: string;
price: string | null;
city: string | null;
neighborhood: string | null;
cover_photo_url: string | null;
}
export interface SavedProperty {
id: string;
property_id: string | null;
property: {
id: string;
title: string;
slug: string;
} | null;
property: PropertyCard | null;
created_at: string;
}
export interface UpdateProfilePayload {
name: string;
}
export interface UpdateProfileResponse {
id: string;
name: string;
email: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
}

View file

@ -5,4 +5,12 @@ export interface HomepageConfig {
hero_cta_url: string
featured_properties_limit: number
hero_image_url?: string | null
hero_image_light_url?: string | null
hero_image_dark_url?: string | null
}
export interface HomepageHeroImagesPayload {
hero_image_url?: string | null
hero_image_light_url?: string | null
hero_image_dark_url?: string | null
}

View file

@ -50,4 +50,6 @@ export interface ContactFormData {
email: string
phone: string
message: string
source?: string
source_detail?: string
}

View file

@ -1,7 +1,7 @@
# API Catalog Enhancements — Contrato de Interface
**Feature**: `024-filtro-busca-avancada`
**Tipo de mudança**: Adição de campo somente-leitura em endpoints existentes (backward-compatible)
**Feature**: `024-filtro-busca-avancada`
**Tipo de mudança**: Adição de campo somente-leitura em endpoints existentes (backward-compatible)
**Versão da API**: `/api/v1` (sem mudança de versão — campo adicional não quebra clientes existentes)
---

View file

@ -1,6 +1,6 @@
# Implementation Plan: Filtro de Busca Avançada — FilterSidebar
**Branch**: `024-filtro-busca-avancada` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
**Branch**: `024-filtro-busca-avancada` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/024-filtro-busca-avancada/spec.md`
---
@ -13,14 +13,14 @@ Enriquecer os endpoints de catálogo existentes com o campo `property_count` (CO
## Technical Context
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
**Storage**: PostgreSQL 16 — sem novas tabelas ou migrations (`property_count` é calculado via `func.count` + `outerjoin` no ORM, não persistido)
**Testing**: pytest (backend — testes de integração nos endpoints enriquecidos)
**Target Platform**: Browser SPA (desktop); Linux server via Docker
**Project Type**: web-service (Flask REST API) + SPA (React)
**Performance Goals**: busca cross-categoria < 50 ms (processamento local, NFR-001); endpoints de catálogo < 300 ms p95 (COUNT em tabelas pequenas, < 5 k registros)
**Constraints**: sem nova dependência npm ou Python; sem rotas novas; filtros mobile fora de escopo; estado de expansão das seções não persistido em `localStorage` (NFR per spec)
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
**Storage**: PostgreSQL 16 — sem novas tabelas ou migrations (`property_count` é calculado via `func.count` + `outerjoin` no ORM, não persistido)
**Testing**: pytest (backend — testes de integração nos endpoints enriquecidos)
**Target Platform**: Browser SPA (desktop); Linux server via Docker
**Project Type**: web-service (Flask REST API) + SPA (React)
**Performance Goals**: busca cross-categoria < 50 ms (processamento local, NFR-001); endpoints de catálogo < 300 ms p95 (COUNT em tabelas pequenas, < 5 k registros)
**Constraints**: sem nova dependência npm ou Python; sem rotas novas; filtros mobile fora de escopo; estado de expansão das seções não persistido em `localStorage` (NFR per spec)
**Scale/Scope**: 3 schemas Pydantic editados, 2 rotas Flask editadas, 1 componente React reformulado (~600 linhas → ~800 linhas), 2 arquivos de tipos TypeScript editados
---
@ -200,8 +200,8 @@ Section recebe `open={openSections[key]}` + `onToggle={() => toggleSection(key)}
**Rationale**: Evita migration desnecessária (Constitution IV). `property_count` é dado de leitura; persistir seria denormalização sem benefício real dado o volume (< 5 k imóveis). Subquery em tabelas pequenas é negligenciável em performance.
**Alternativas descartadas**:
- SQLAlchemy `column_property` com correlated subquery: mais limpo no ORM mas adiciona complexidade ao modelo sem ganho real (usado uma vez).
**Alternativas descartadas**:
- SQLAlchemy `column_property` com correlated subquery: mais limpo no ORM mas adiciona complexidade ao modelo sem ganho real (usado uma vez).
- Coluna persistida com trigger: over-engineering (Constitution VI); requer migration + lógica de atualização.
**Impacto na serialização**: Os routes handlers passam a construir dicts manualmente para City/Neighborhood. Para PropertyType (hierárquico), o `property_count` é injetado nos subtypes após serialização com `model_dump() | {'property_count': count_map.get(sub.id, 0)}`.

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Favoritos Locais para Visitantes Não Autenticados
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec aprovado na primeira validação — todos os itens passam.
- A page `/favoritos` (pública) e `/area-do-cliente/favoritos` (autenticada) são destinos distintos, conforme explicitado nas Assumptions.
- Sincronização de favoritos locais ao login está coberta pela US4 e FR-013 a FR-017.

View file

@ -0,0 +1,255 @@
# Plan: Feature 025 — Favoritos Locais para Visitantes
**Branch**: `025-favoritos-locais`
**Spec**: `specs/025-favoritos-locais/spec.md`
**Status**: Ready to implement
**Backend changes**: Nenhum
---
## Visão Geral
Extensão do sistema de favoritos existente (autenticado via API) para suportar visitantes não autenticados via `localStorage`. Inclui:
1. `FavoritesContext` armazena favoritos localmente quando não autenticado
2. Merge automático ao fazer login (local → servidor)
3. Página pública `/favoritos` acessível sem conta
4. `HeartButton` permite toggle sem redirecionar para login
---
## Análise do Estado Atual
### O que já existe
| Arquivo | Responsabilidade atual |
|---|---|
| `FavoritesContext.tsx` | Gerencia favoritos autenticados via API; limpa estado no logout |
| `HeartButton.tsx` | Redireciona para `/login` se não autenticado |
| `FavoritesPage.tsx` | Lista favoritos da conta (`/area-do-cliente/favoritos`) |
| `AuthContext.tsx` | `login()` atualiza `user` e `token`; sem callback pós-login |
| `clientArea.ts` | `addFavorite`, `removeFavorite`, `getFavorites` via API |
### Problema de dados para a página pública
A `getProperty(slug)` busca por slug — e a API só expõe propriedades por slug. Como `localStorage` armazenaria apenas IDs, buscar dados da propriedade exigiria uma chamada extra por imóvel.
**Decisão**: Armazenar snapshots mínimos junto com o ID. O `HeartButton` recebe o objeto `Property` como prop opcional e o contexto o persiste localmente.
---
## Arquitetura de Dados
### localStorage
```
Chave : "local_favorites"
Valor : JSON.stringify(LocalFavoriteEntry[])
```
```typescript
// Definido em FavoritesContext.tsx
interface LocalFavoriteEntry {
id: string; // property UUID
title: string;
slug: string;
price: string;
type: 'venda' | 'aluguel';
photos: Array<{ url: string; alt_text: string }>;
city: { name: string } | null;
}
```
`favoriteIds` (Set<string>) é **derivado** das entries → lookup O(1) para o `HeartButton`.
---
## Decisões Técnicas
### 1. Merge no login — onde colocar?
**Opção A**: Hook dentro de `AuthContext.login()` (callback explícito)
**Opção B**: `useEffect` em `FavoritesContext` reagindo à mudança `isAuthenticated` false → true ✅
**Escolha: Opção B** — sem acoplamento entre contextos; o `FavoritesContext` já observa `isAuthenticated`.
Lógica no `useEffect([isAuthenticated])`:
```
Se isAuthenticated acabou de virar true:
1. Carregar local entries do localStorage
2. Se entries.length > 0:
a. Carregar favoritos do servidor (getFavorites)
b. Para cada entry local não presente no servidor → addFavorite(entry.id)
c. Limpar localStorage["local_favorites"]
3. Carregar favoritos do servidor normalmente (estado final)
```
**Rollback no merge**: Se uma chamada `addFavorite` falhar, os favoritos locais são preservados e não apagados. O merge é retentado no próximo login.
### 2. `toggle()` — assinatura
```typescript
// Atual (autenticado only):
toggle(propertyId: string): Promise<void>
// Novo (suporta ambos):
toggle(propertyId: string, snapshot?: LocalFavoriteEntry): Promise<void>
```
Quando não autenticado: atualiza localStorage + estado. Sem chamada API.
Quando autenticado: comportamento atual (API + optimistic update). `snapshot` ignorado.
### 3. `HeartButton` — passar o snapshot
Prop `property?: Property` adicionada. Usada para construir o `LocalFavoriteEntry` ao toggle local.
Quando `property` não é passado e o usuário não está autenticado: toggle funciona **somente pelo ID** (sem snapshot armazenado). O imóvel aparecerá como favorito, mas não exibirá dados na `PublicFavoritesPage`. Isso é aceitável para backward compatibility.
Remoção do `navigate('/login')` — usuários não autenticados podem favoritar diretamente.
### 4. Página pública `/favoritos`
- Lê `localEntries` exposto pelo `FavoritesContext`
- Se `isAuthenticated`: exibe banner de redirecionamento para `/area-do-cliente/favoritos`
- Se não autenticado: exibe cards com base nos snapshots locais
- Estado vazio: link para `/imoveis`
- Banner de incentivo ao cadastro (P2): sempre visível quando não autenticado
### 5. Favoritos ao fazer logout
**Decisão**: Manter localStorage — o visitante não perde favoritos ao deslogar.
O `useEffect` continua carregando do localStorage quando `isAuthenticated = false`.
---
## Fluxo de Dados
```
[Não autenticado]
Clique HeartButton
toggle(id, snapshot)
localStorage["local_favorites"] updated
favoriteIds state updated (derivado)
HeartButton muda visual imediatamente
[Login]
AuthContext.login() → setUser() → isAuthenticated vira true
FavoritesContext useEffect([isAuthenticated]) dispara
Lê localEntries do localStorage
Se localEntries.length > 0:
getFavorites() → serverIds
Para cada entry não em serverIds → addFavorite(entry.id)
Se todos addFavorite OK → removeItem("local_favorites")
Se erro → preserva localStorage (sem limpar)
setFavoriteIds(serverIds + merged)
```
---
## Arquivos a Criar
### `frontend/src/pages/PublicFavoritesPage.tsx` (novo)
Página pública em `/favoritos`:
- Usa `useFavorites().localEntries` para listar cards
- `useAuth().isAuthenticated` para condicionar banner/redirecionamento
- Cards com foto, título, preço, cidade, link para `/imoveis/:slug`
- Botão de remoção em cada card (chama `toggle(id)`)
- Estado vazio com link para `/imoveis`
- Banner de incentivo: link para `/cadastro` e `/login?next=/favoritos`
---
## Arquivos a Modificar
### `frontend/src/contexts/FavoritesContext.tsx`
Mudanças:
1. Adicionar `LOCAL_KEY = 'local_favorites'`
2. Adicionar `LocalFavoriteEntry` interface
3. Adicionar `localEntries: LocalFavoriteEntry[]` ao estado
4. Adicionar `readLocal()` / `writeLocal()` helpers
5. `useEffect([isAuthenticated])`:
- Se `false`: carregar do localStorage → derivar `favoriteIds`
- Se `true` (transition): executar merge → depois carregar do servidor
6. Atualizar `toggle()`: sem auth → localStorage; com auth → API (existente)
7. Expor `localEntries` no `FavoritesContextValue`
### `frontend/src/components/HeartButton.tsx`
Mudanças:
1. Adicionar prop `property?: Property`
2. Remover `navigate('/login')` no path não autenticado
3. Construir `LocalFavoriteEntry` a partir de `property` e passar para `toggle()`
### `frontend/src/App.tsx`
Mudanças:
1. Importar `PublicFavoritesPage`
2. Adicionar `<Route path="/favoritos" element={<PublicFavoritesPage />} />` (rota pública)
### `frontend/src/components/PropertyRowCard.tsx`
Mudanças:
1. Passar `property={property}` para `<HeartButton />`
### `frontend/src/components/PropertyGridCard.tsx`
Mudanças:
1. Passar `property={property}` para `<HeartButton />`
### `frontend/src/pages/PropertyDetailPage.tsx`
Mudanças:
1. Passar `property={property}` para `<HeartButton />`
---
## Arquivos Sem Mudanças
- `AuthContext.tsx` — sem acoplamento necessário
- `FavoritesPage.tsx` (cliente autenticado) — sem mudanças
- `clientArea.ts` — sem mudanças
- Backend — zero alterações
---
## Tratamento de Edge Cases
| Cenário | Comportamento |
|---|---|
| Modo navegação anônima (sem localStorage) | `try/catch` em `readLocal()`; array vazio como fallback |
| Imóvel removido do sistema | Card exibe dados do snapshot; link para detalhe pode retornar 404 (aceitável) |
| Merge falha por erro de rede | localStorage preservado; retentado no próximo login |
| 50+ favoritos locais | Renderização React lista longa; sem paginação (aceitável na v1) |
| HeartButton sem `property` prop | Toggle funciona; sem snapshot salvo; imóvel não aparece em PublicFavoritesPage |
| Usuário autenticado acessa `/favoritos` | Banner com link para `/area-do-cliente/favoritos`; não redireciona automaticamente |
---
## Sequência de Implementação
```
1. FavoritesContext.tsx ← base de tudo
2. HeartButton.tsx ← unlock não autenticado
3. PropertyRowCard.tsx ← passa property ao HeartButton
4. PropertyGridCard.tsx ← idem
5. PropertyDetailPage.tsx ← idem
6. PublicFavoritesPage.tsx ← página pública
7. App.tsx ← registrar rota
```
---
## Validação Manual (Smoke Tests)
1. Sem login → clicar coração em card → ícone preenchido → reload da página → continua preenchido
2. Sem login → `/favoritos` → card aparece com foto e link
3. Fazer login com 2 favoritos locais → `/area-do-cliente/favoritos` → imóveis aparecem
4. Favorito já no servidor antes do login → sem duplicata após merge
5. Usuário autenticado → `/favoritos` → banner exibe link para área do cliente

View file

@ -0,0 +1,166 @@
# Feature Specification: Favoritos Locais para Visitantes Não Autenticados
**Feature Branch**: `025-favoritos-locais`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
O sistema já oferece uma lista de favoritos para usuários autenticados, acessível em `/area-do-cliente/favoritos`. No entanto, visitantes sem cadastro não podem salvar imóveis de interesse durante a navegação — qualquer imóvel marcado como favorito é esquecido ao trocar de página ou fechar o browser.
Este spec cobre a adição de uma experiência de favoritos para visitantes não autenticados, inteiramente do lado do cliente, sem necessidade de cadastro imediato. Inclui também uma página pública `/favoritos` acessível a qualquer visitante e a sincronização automática dos favoritos locais com a conta do servidor quando o visitante decide se cadastrar ou fazer login.
---
## User Scenarios & Testing
### User Story 1 — Visitante Favorita Imóvel Sem Se Cadastrar (Priority: P1)
Um visitante sem cadastro que encontrou um imóvel interessante na listagem ou na página de detalhes quer marcá-lo como favorito para revisitar depois — sem precisar criar uma conta no momento.
**Why this priority**: Exigir cadastro para salvar favoritos cria uma barreira de entrada que reduz o engajamento de visitantes em fase de descoberta. Permitir favoritos locais elimina essa fricção e aumenta o tempo médio de sessão e a probabilidade de conversão posterior.
**Independent Test**: Sem estar logado, clicar no ícone de coração de um card de imóvel na listagem `/imoveis`, verificar que o ícone muda visualmente para preenchido, navegar para outra página e retornar — o imóvel deve continuar marcado como favorito.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado na página `/imoveis`, **When** ele clica no ícone de favorito de um card, **Then** o ícone muda para o estado "favoritado" (coração preenchido) instantaneamente e o imóvel é salvo localmente.
2. **Given** um visitante não autenticado na página de detalhes de um imóvel, **When** ele clica no botão de favorito, **Then** o ícone muda para o estado "favoritado" e o imóvel é salvo localmente.
3. **Given** um imóvel já favoritado localmente, **When** o visitante clica novamente no ícone de favorito, **Then** o imóvel é removido dos favoritos locais e o ícone volta ao estado vazio.
4. **Given** um imóvel favoritado por um visitante não autenticado, **When** o visitante fecha o browser e reabre o site, **Then** o ícone de favorito daquele imóvel ainda aparece preenchido.
5. **Given** múltiplos imóveis favoritados localmente, **When** o visitante navega pela listagem, **Then** todos os imóveis favoritados exibem o ícone preenchido consistentemente.
---
### User Story 2 — Visitante Acessa a Página Pública de Favoritos (Priority: P1)
Um visitante não autenticado que favoritou imóveis quer ver todos os seus imóveis salvos em uma única página, com informações resumidas e acesso direto à página de detalhes de cada um.
**Why this priority**: Sem uma página dedicada, os favoritos locais têm valor limitado — o usuário não consegue revisitar rapidamente os imóveis que marcou. A página `/favoritos` é o destino principal da funcionalidade e torna a experiência comparável à dos usuários autenticados.
**Independent Test**: Favoritar 3 imóveis como visitante não autenticado, navegar para `/favoritos`, verificar que os 3 imóveis aparecem com foto, título, preço e link para o detalhe.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado com imóveis favoritados localmente, **When** ele acessa `/favoritos`, **Then** a página exibe todos os imóveis salvos com foto, título, preço e link para a página de detalhes.
2. **Given** a página `/favoritos` com imóveis exibidos, **When** o visitante clica no ícone de remoção de um imóvel, **Then** o imóvel é removido da lista imediatamente, sem recarregar a página.
3. **Given** um visitante não autenticado sem nenhum favorito salvo, **When** ele acessa `/favoritos`, **Then** uma mensagem de estado vazio é exibida com orientação para navegar na listagem e salvar imóveis.
4. **Given** a página `/favoritos` acessível sem autenticação, **When** um visitante não autenticado acessa `/favoritos` diretamente pela URL, **Then** a página carrega normalmente sem redirecionar para login.
5. **Given** a página `/favoritos` com imóveis exibidos, **When** o visitante clica no card de um imóvel, **Then** é redirecionado para a página de detalhes daquele imóvel.
---
### User Story 3 — Banner de Incentivo ao Cadastro na Página de Favoritos (Priority: P2)
Um visitante não autenticado na página `/favoritos` é informado de que pode salvar sua lista de favoritos permanentemente criando uma conta, e recebe um caminho claro para o cadastro.
**Why this priority**: A página `/favoritos` é o momento de maior intenção do visitante — ele demonstrou interesse explícito em imóveis e está revisitando-os. É o ponto de conversão mais natural para incentivar o cadastro.
**Independent Test**: Acessar `/favoritos` sem estar autenticado e verificar que um banner/card de convite ao cadastro é exibido, com botão para criar conta e explicação dos benefícios de sincronização.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado na página `/favoritos`, **When** a página é carregada, **Then** um banner informativo é exibido explicando que criar uma conta permite salvar os favoritos na nuvem e acessá-los em qualquer dispositivo.
2. **Given** o banner de incentivo visível, **When** o visitante clica em "Criar conta", **Then** é redirecionado para a página de cadastro.
3. **Given** o banner de incentivo visível, **When** o visitante clica em "Entrar", **Then** é redirecionado para a página de login com retorno automático para `/favoritos` após autenticação.
4. **Given** um usuário autenticado na página `/favoritos`, **When** a página é carregada, **Then** o banner de incentivo ao cadastro não é exibido.
---
### User Story 4 — Sincronização de Favoritos Locais ao Fazer Login (Priority: P2)
Um visitante que acumulou favoritos locais e decide se autenticar tem seus favoritos locais automaticamente mesclados com os favoritos já salvos em sua conta, sem precisar refavoritar os imóveis manualmente.
**Why this priority**: Sem sincronização, o usuário perde toda a lista de favoritos locais ao fazer login, o que penaliza exatamente o comportamento desejado (visitar imóveis, favoritar e depois se cadastrar). A perda de dados cria frustração e desincentiva o uso da funcionalidade.
**Independent Test**: Favoritar 3 imóveis sem estar logado (A, B, C). Fazer login em uma conta que já tem o imóvel A nos favoritos do servidor. Navegar para `/area-do-cliente/favoritos` e verificar que A, B e C aparecem — sem duplicatas.
**Acceptance Scenarios**:
1. **Given** um visitante com imóveis A, B e C favoritados localmente, **When** ele faz login com sucesso, **Then** os imóveis B e C (que não estavam nos favoritos do servidor) são adicionados automaticamente à conta do servidor.
2. **Given** que o imóvel A já estava nos favoritos do servidor antes do login, **When** o visitante faz login com A, B e C nos favoritos locais, **Then** o imóvel A não é duplicado nos favoritos do servidor.
3. **Given** que a sincronização foi realizada com sucesso, **When** o login é concluído, **Then** os favoritos locais são removidos do armazenamento local do browser.
4. **Given** um visitante sem nenhum favorito local, **When** ele faz login, **Then** nenhuma operação de merge é realizada e os favoritos do servidor não são alterados.
5. **Given** que a sincronização falha por erro de rede após o login, **When** o visitante acessa a área de favoritos, **Then** os favoritos locais são preservados (não apagados) para retentativa futura.
---
### Edge Cases
- O que acontece com os favoritos locais quando o visitante usa o modo de navegação anônima (aba privada)?
- Como o sistema exibe o estado de carregamento enquanto busca os dados dos imóveis favoritados na página `/favoritos`?
- O que acontece se um imóvel favoritado localmente for removido ou desativado no sistema antes de o visitante acessar `/favoritos`?
- Como o ícone de favorito se comporta em imóveis que aparecem em múltiplos contextos (listagem, detalhe, resultados de busca) simultaneamente na mesma sessão?
- O que acontece com os favoritos locais ao fazer logout — eles são mantidos ou limpos?
- Como o sistema lida com uma lista muito grande de favoritos locais (ex.: 50+ imóveis) em termos de desempenho na página `/favoritos`?
- O que acontece se o visitante tentar acessar `/favoritos` em um browser que não suporta armazenamento local?
---
## Requirements
### Functional Requirements
#### Grupo 1 — Favoritar Sem Autenticação
- **FR-001**: O sistema DEVE permitir que visitantes não autenticados adicionem e removam imóveis de uma lista de favoritos local sem exigir cadastro ou login.
- **FR-002**: O estado de favorito (favoritado ou não) de cada imóvel DEVE ser persistido entre sessões do browser para visitantes não autenticados.
- **FR-003**: O ícone de favorito em cards de listagem e na página de detalhes DEVE refletir o estado local em tempo real para visitantes não autenticados.
- **FR-004**: O toggle de favorito DEVE funcionar de forma idêntica (mesma interface visual e resposta imediata) tanto para usuários autenticados quanto para visitantes não autenticados.
#### Grupo 2 — Página Pública de Favoritos
- **FR-005**: O sistema DEVE disponibilizar uma rota pública `/favoritos` acessível sem autenticação que exiba todos os imóveis favoritados pelo visitante.
- **FR-006**: A página `/favoritos` DEVE buscar os dados atualizados de cada imóvel favoritado pelo seu identificador e exibi-los com foto, título, preço, tipo, número de quartos, área e link para o detalhe.
- **FR-007**: A página `/favoritos` DEVE exibir um estado vazio com orientação de navegação quando o visitante não tiver nenhum favorito salvo.
- **FR-008**: O visitante DEVE poder remover imóveis individualmente da lista na página `/favoritos` sem recarregar a página.
- **FR-009**: A página `/favoritos` NÃO DEVE redirecionar visitantes não autenticados para a página de login.
#### Grupo 3 — Banner de Incentivo ao Cadastro
- **FR-010**: A página `/favoritos` DEVE exibir um banner de incentivo ao cadastro quando o visitante não estiver autenticado, explicando os benefícios de sincronização entre dispositivos.
- **FR-011**: O banner DEVE conter ações claras para cadastro e para login, com retorno automático à página `/favoritos` após autenticação.
- **FR-012**: O banner de incentivo ao cadastro NÃO DEVE ser exibido para usuários autenticados.
#### Grupo 4 — Sincronização no Login
- **FR-013**: Ao concluir o processo de autenticação com sucesso, o sistema DEVE verificar se há favoritos locais armazenados no browser.
- **FR-014**: Caso existam favoritos locais, o sistema DEVE adicionar à conta do servidor apenas os imóveis que ainda não constam nos favoritos do usuário, evitando duplicatas.
- **FR-015**: Após a sincronização bem-sucedida, o sistema DEVE limpar os favoritos locais do browser.
- **FR-016**: Em caso de falha na sincronização, os favoritos locais DEVEM ser preservados para retentativa e o usuário DEVE ser informado discretamente.
- **FR-017**: Usuários que fazem login sem favoritos locais NÃO DEVEM ter os favoritos do servidor alterados.
### Key Entities
- **Favorito Local**: Registro do lado do cliente representando o interesse de um visitante em um imóvel. Identificado pelo `property_id` do imóvel. Não tem representação no banco de dados — existe exclusivamente no armazenamento local do browser do visitante.
- **Imóvel (Property)**: Unidade imobiliária com identificador único, título, preço, tipo, área, quartos e fotos. Consultado pelo identificador na página `/favoritos` para exibir informações atualizadas.
- **Lista de Favoritos Locais**: Conjunto de `property_id`s armazenados localmente pelo visitante, sem limite explícito de tamanho nesta versão.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Visitantes não autenticados conseguem favoritar um imóvel em 1 clique, sem fluxo de cadastro ou modal intermediário.
- **SC-002**: O estado de favorito é preservado em 100% das navegações entre páginas e reabertas de browser (sem autenticação).
- **SC-003**: Visitantes conseguem acessar e visualizar todos os seus imóveis favoritados em `/favoritos` em menos de 3 segundos após o carregamento da página.
- **SC-004**: Após o login, 100% dos favoritos locais que não estavam no servidor são sincronizados automaticamente, sem nenhuma ação adicional do usuário.
- **SC-005**: Zero duplicatas de favoritos são criadas no servidor durante o processo de merge ao fazer login.
- **SC-006**: A página `/favoritos` é acessível sem autenticação — 0% de redirecionamentos indesejados para a tela de login para visitantes não autenticados.
- **SC-007**: O banner de incentivo ao cadastro na página `/favoritos` exibe claramente os benefícios e os caminhos para cadastro e login em uma leitura de menos de 10 segundos.
---
## Assumptions
- Os identificadores (`property_id`) dos imóveis são valores estáveis e únicos que não mudam após a criação do imóvel.
- O contexto de autenticação já expõe o estado do usuário (autenticado/não autenticado) de forma acessível aos componentes de favorito existentes.
- A página `/area-do-cliente/favoritos` (para usuários autenticados) permanece inalterada por este spec; a nova página `/favoritos` é um destino separado.
- Imóveis removidos ou desativados no sistema durante o período em que estavam favoritados localmente são tratados exibindo um estado de "imóvel indisponível" na página `/favoritos`, sem remover automaticamente da lista local.
- A lista de favoritos locais NÃO é sincronizada em tempo real entre múltiplos dispositivos do mesmo visitante (sem autenticação não há como identificar o usuário entre dispositivos).
- Favoritos locais são mantidos após o logout — um usuário que se desloga não perde imóveis que eventualmente tenha favoritado antes de se autenticar naquela sessão.
- O backend já possui ou pode receber uma rota para consultar múltiplos imóveis por lista de identificadores, usada pela página `/favoritos` para buscar dados em lote.
- Não há novas tabelas ou alterações no banco de dados nesta feature — toda a persistência para visitantes não autenticados é exclusivamente client-side.

View file

@ -0,0 +1,265 @@
# Tasks: Feature 025 — Favoritos Locais para Visitantes
**Branch**: `025-favoritos-locais`
**Spec**: `specs/025-favoritos-locais/spec.md`
**Plan**: `specs/025-favoritos-locais/plan.md`
**Backend changes**: Nenhum
---
## Fase 1 — Foundational: Interface e Dados (Prerequisito para todos os user stories)
> Objetivo: Definir o contrato de dados `LocalFavoriteEntry` e os utilitários de
> localStorage usados por todo o restante da feature. Nenhuma mudança visível ao usuário.
- [ ] T001 Adicionar interface `LocalFavoriteEntry` e constante `LOCAL_FAV_KEY` em `frontend/src/contexts/FavoritesContext.tsx`
```typescript
// Logo antes de FavoritesContextValue
const LOCAL_FAV_KEY = 'local_favorites';
export interface LocalFavoriteEntry {
id: string;
title: string;
slug: string;
price: string;
type: 'venda' | 'aluguel';
photos: Array<{ url: string; alt_text: string }>;
city: { name: string } | null;
}
```
- [ ] T002 Adicionar `localEntries` ao tipo `FavoritesContextValue` em `frontend/src/contexts/FavoritesContext.tsx`
```typescript
interface FavoritesContextValue {
favoriteIds: Set<string>;
localEntries: LocalFavoriteEntry[]; // novo
toggle: (propertyId: string, snapshot?: LocalFavoriteEntry) => Promise<void>;
isLoading: boolean;
}
```
---
## Fase 2 — User Story 1: Visitante Favorita Imóvel Sem Se Cadastrar (P1)
> **Objetivo**: Ícone de coração funciona para não-autenticados, persiste via localStorage
> e sobrevive a navegação e recarga do browser.
>
> **Teste independente**: Sem login, clicar no coração de um card em `/imoveis`,
> navegar para outra página e retornar — imóvel ainda marcado como favorito.
- [ ] T003 [US1] Refatorar `useEffect([isAuthenticated])` em `frontend/src/contexts/FavoritesContext.tsx` para inicializar `favoriteIds` e `localEntries` a partir do `localStorage` quando não autenticado
**Lógica**:
- Se `!isAuthenticated`: lê `localStorage.getItem(LOCAL_FAV_KEY)`, faz parse para `LocalFavoriteEntry[]`, popula `localEntries` e deriva `favoriteIds` como `new Set(entries.map(e => e.id))`
- Se `isAuthenticated`: comportamento atual (fetch da API); `localEntries` permanece `[]`
- [ ] T004 [US1] Atualizar `toggle()` em `frontend/src/contexts/FavoritesContext.tsx` para aceitar `snapshot?: LocalFavoriteEntry` e tratar o caso não-autenticado via localStorage
**Lógica quando `!isAuthenticated`**:
```typescript
const wasIn = favoriteIds.has(propertyId);
const next = wasIn
? localEntries.filter(e => e.id !== propertyId)
: [...localEntries, snapshot ?? { id: propertyId, title: '', slug: '', price: '', type: 'venda', photos: [], city: null }];
localStorage.setItem(LOCAL_FAV_KEY, JSON.stringify(next));
setLocalEntries(next);
setFavoriteIds(new Set(next.map(e => e.id)));
return;
```
Quando autenticado: comportamento atual; `snapshot` ignorado.
- [ ] T005 [US1] Atualizar `frontend/src/components/HeartButton.tsx` — adicionar prop `snapshot?: LocalFavoriteEntry`, remover redirecionamento para `/login` e chamar `toggle(propertyId, snapshot)`
```typescript
interface HeartButtonProps {
propertyId: string;
snapshot?: LocalFavoriteEntry; // novo
className?: string;
}
// handleClick — remover: if (!isAuthenticated) { navigate('/login'); return; }
// Novo handleClick:
async function handleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
await toggle(propertyId, snapshot);
}
```
Remover import de `useNavigate` e `useAuth` se não forem mais necessários após esta mudança.
- [ ] T006 [P] [US1] Atualizar `frontend/src/components/PropertyRowCard.tsx` — construir snapshot e passar para `<HeartButton>`
A prop `property` já é do tipo `Property`. Compor `LocalFavoriteEntry` a partir de `property`:
```tsx
const favSnapshot: LocalFavoriteEntry = {
id: property.id,
title: property.title,
slug: property.slug,
price: property.price,
type: property.type,
photos: property.photos.slice(0, 1).map(p => ({ url: p.url, alt_text: p.alt_text })),
city: property.city ? { name: property.city.name } : null,
};
// Usar em: <HeartButton propertyId={property.id} snapshot={favSnapshot} />
```
- [ ] T007 [P] [US1] Atualizar `frontend/src/components/PropertyCard.tsx` — construir snapshot e passar para `<HeartButton>` (mesma lógica de T006)
- [ ] T008 [P] [US1] Atualizar `frontend/src/pages/PropertyDetailPage.tsx` — construir snapshot a partir de `property` e passar para `<HeartButton propertyId={property.id} snapshot={favSnapshot} />`
> O objeto `property` completo está disponível no escopo onde `HeartButton` é renderizado (linha 117).
---
## Fase 3 — User Story 2: Página Pública de Favoritos `/favoritos` (P1)
> **Objetivo**: Visitante não autenticado acessa `/favoritos` e vê os imóveis salvos
> localmente com foto, título, preço e link para o detalhe.
>
> **Teste independente**: Favoritar 3 imóveis, acessar `/favoritos` — 3 imóveis exibidos.
- [ ] T009 [US2] Criar `frontend/src/pages/PublicFavoritesPage.tsx`
**Requisitos**:
- Usa `useFavorites()` para ler `localEntries`, `favoriteIds`, `toggle`
- Estado de carregamento: verificar `isLoading`
- Estado vazio: mensagem + link para `/imoveis`
- Grid responsivo: 1 col mobile → 2 col sm → 3 col lg (igual ao `FavoritesPage.tsx` existente)
- Cada card exibe: foto (primeira do array `photos`), título, preço formatado (`Intl.NumberFormat pt-BR`), badge de tipo (`venda`/`aluguel`), cidade, link para `/imoveis/{slug}`, botão de remover (chama `toggle(entry.id)`)
- **Não** chama API — usa apenas `localEntries` do contexto
- Acessível sem autenticação (sem `ProtectedRoute`)
- [ ] T010 [US2] Registrar rota `/favoritos` em `frontend/src/App.tsx`
```tsx
import PublicFavoritesPage from './pages/PublicFavoritesPage';
// Dentro de <Routes>, após /politica-de-privacidade:
<Route path="/favoritos" element={<PublicFavoritesPage />} />
```
---
## Fase 4 — User Story 3: Banner de Incentivo ao Cadastro (P2)
> **Objetivo**: Visitante não autenticado em `/favoritos` vê banner com CTA para
> criar conta ou fazer login.
>
> **Teste independente**: Acessar `/favoritos` sem autenticação e verificar banner com
> botões "Criar conta" e "Entrar".
- [ ] T011 [US3] Adicionar `SignupBanner` inline em `frontend/src/pages/PublicFavoritesPage.tsx`
**Regra**: Banner visível apenas quando `!isAuthenticated` (obter de `useAuth()`).
**Conteúdo**:
- Ícone de coração ou nuvem
- Título: "Salve seus favoritos em qualquer dispositivo"
- Texto: "Crie uma conta gratuita para sincronizar sua lista de imóveis favoritos e acessá-la de qualquer lugar."
- Botão primário: "Criar conta" → navega para `/cadastro`
- Link secundário: "Já tenho conta — Entrar" → navega para `/login?next=/favoritos`
**Posicionamento**: Acima do grid de imóveis (ou abaixo, se a lista for vazia — nesse caso, deve ser o destaque principal da tela).
---
## Fase 5 — User Story 4: Sincronização de Favoritos Locais ao Fazer Login (P2)
> **Objetivo**: Ao fazer login, favoritos locais são mesclados automaticamente com os
> favoritos do servidor, sem duplicatas, e o localStorage é limpo.
>
> **Teste independente**: Favoritar A, B, C sem login; login em conta com A no servidor;
> `/area-do-cliente/favoritos` exibe A, B, C sem duplicatas.
- [ ] T012 [US4] Adicionar `useEffect([isAuthenticated])` de merge em `frontend/src/contexts/FavoritesContext.tsx`
**Lógica** (executada quando `isAuthenticated` muda de `false` para `true`):
```typescript
useEffect(() => {
if (!isAuthenticated) return;
const raw = localStorage.getItem(LOCAL_FAV_KEY);
if (!raw) return; // nada para sincronizar
const localEntries: LocalFavoriteEntry[] = JSON.parse(raw);
if (localEntries.length === 0) return;
(async () => {
try {
const serverFavs = await getFavorites();
const serverIds = new Set(serverFavs.filter((s: any) => s.property_id).map((s: any) => s.property_id as string));
const toAdd = localEntries.filter(e => !serverIds.has(e.id));
await Promise.allSettled(toAdd.map(e => addFavorite(e.id)));
// Limpar localStorage somente se todas as chamadas foram resolvidas (success ou already-exists)
localStorage.removeItem(LOCAL_FAV_KEY);
setLocalEntries([]);
} catch {
// Falha de rede: preservar localStorage para retentativa no próximo login
}
})();
}, [isAuthenticated]);
```
> Este `useEffect` é **separado** do `useEffect` de carregamento inicial (T003).
> Ordem de execução no mesmo ciclo de `isAuthenticated=true`:
> 1. `useEffect` de merge (T012) → adiciona ao servidor e limpa localStorage
> 2. `useEffect` de carregamento (T003) → busca favoritos atualizados do servidor
---
## Fase 6 — Polish & Verificações Finais
- [ ] T013 [P] Verificar `frontend/src/contexts/FavoritesContext.tsx` — confirmar que `localEntries` é exposto no valor do contexto e que o estado é reiniciado corretamente no logout (`isAuthenticated = false` → limpar `localEntries` **do estado**, mas **preservar** o `localStorage["local_favorites"]`)
> Ao fazer logout, os favoritos locais do localStorage são mantidos para que o visitante não os perca caso retorne sem estar logado.
- [ ] T014 [P] Verificar `frontend/src/pages/client/FavoritesPage.tsx` — confirmar que não há quebras de tipo após a alteração da assinatura de `toggle` (o `snapshot` é opcional, não deve impactar chamadas existentes)
---
## Dependências entre Fases
```
T001 → T002 → T003 → T004 ─┬─ T006 (P)
├─ T007 (P)
└─ T008 (P)
T004 → T005 (HeartButton)
T003, T004 → T009 (PublicFavoritesPage)
T009 → T010 (Rota App.tsx)
T009 → T011 (Banner)
T003, T004 → T012 (Merge login)
```
## Execução em Paralelo por Fase
| Fase | Tasks paralelas |
|------|----------------|
| Fase 1 | T001 → T002 (sequencial — mesmo arquivo) |
| Fase 2 | T003 → T004 → T005 (sequencial — mesmo arquivo); T006, T007, T008 em paralelo após T005 |
| Fase 3 | T009 → T010 (sequencial — T010 depende de T009) |
| Fase 4 | T011 (independente de T010, só depende de T009) |
| Fase 5 | T012 (independente das fases 3-4) |
| Fase 6 | T013, T014 em paralelo |
## Escopo MVP (entrega mínima P1)
Fases 1 + 2 + 3 (T001T010): visitante favorita imóveis localmente e acessa a página `/favoritos`.
Fases 45 (T011T012) entregam as histórias P2 (banner + merge no login).
---
## Contagem de Tasks
| Fase | Tasks | User Story |
|------|-------|-----------|
| Foundational | T001, T002 | — |
| US1 | T003T008 | P1 |
| US2 | T009T010 | P1 |
| US3 | T011 | P2 |
| US4 | T012 | P2 |
| Polish | T013T014 | — |
| **Total** | **14 tasks** | |

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Central de Contatos com Rastreamento de Origem
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec validada na criação — todos os itens passaram na primeira iteração.
- Leads legados (sem `source`) tratados via Assumption de compatibilidade retroativa (FR-022 + Assumptions).
- Assunto do formulário `/contato` mapeado na Assumption como parte da `message` para evitar nova coluna não especificada.

View file

@ -0,0 +1,201 @@
# Feature Specification: Central de Contatos com Rastreamento de Origem
**Feature Branch**: `026-central-contatos`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
O sistema já possui um mecanismo básico de captura de leads: visitantes podem enviar mensagens a partir da página de detalhes de um imóvel, e esses registros são armazenados como `ContactLead`. No entanto, a origem de cada contato não é rastreada, o formulário de contato geral na navbar aponta para uma âncora na homepage (sem página própria) e não existe uma página para proprietários interessados em anunciar seus imóveis.
Esta spec cobre a criação de uma **Central de Contatos** unificada com três origens rastreáveis: formulário público de contato geral (`/contato`), formulário de cadastro de residência para anúncio (`/cadastro-residencia`), e o formulário de contato de imóvel já existente. Inclui também a página de administração consolidada para visualizar e filtrar todos os leads por origem, e a correção dos links da navbar para destinos internos.
---
## User Scenarios & Testing
### User Story 1 — Visitante Envia Contato Geral via `/contato` (Priority: P1)
Um visitante do site que deseja tirar dúvidas, solicitar informações ou propor parceria quer enviar uma mensagem para a imobiliária sem precisar acessar a página de um imóvel específico.
**Why this priority**: É o ponto de contato principal do site. Atualmente a navbar aponta "Contato" para uma âncora que pode não estar visível, criando uma experiência frustrante. Disponibilizar uma página dedicada com formulário funcional é o requisito mínimo de contato da imobiliária.
**Independent Test**: Acessar `/contato` sem estar autenticado, preencher todos os campos obrigatórios (nome, e-mail, telefone, assunto, mensagem) e submeter. Verificar que a submissão é aceita com mensagem de confirmação e que o lead aparece na base com `source = "contato"`.
**Acceptance Scenarios**:
1. **Given** um visitante na página `/contato`, **When** ele preenche nome, e-mail, telefone, assunto e mensagem e clica em enviar, **Then** o formulário é submetido com sucesso, uma mensagem de confirmação é exibida e o visitante permanece na página.
2. **Given** um visitante na página `/contato`, **When** ele submete o formulário sem preencher um campo obrigatório, **Then** o campo em falta é destacado com uma mensagem de erro inline e o formulário não é enviado.
3. **Given** um visitante que submeteu o formulário com sucesso, **When** o lead é registrado no sistema, **Then** o campo `source` do registro é `"contato"` e `source_detail` fica vazio.
4. **Given** um visitante na página `/contato`, **When** ele informa um e-mail com formato inválido, **Then** o campo e-mail é destacado com erro de validação antes do envio.
5. **Given** um visitante na página `/contato`, **When** o assunto selecionado é "Anúncio", **Then** o formulário é submetido normalmente com o assunto incluído na mensagem registrada.
---
### User Story 2 — Proprietário Cadastra Imóvel para Anúncio via `/cadastro-residencia` (Priority: P1)
Um proprietário de imóvel que deseja anunciá-lo através da imobiliária quer enviar os dados básicos do imóvel e suas informações de contato para que a equipe entre em contato e dê continuidade ao processo.
**Why this priority**: Representa uma fonte direta de captação de novos imóveis para o portfólio da imobiliária. Sem esse canal, proprietários interessados não têm caminho claro para iniciar o processo de anúncio.
**Independent Test**: Acessar `/cadastro-residencia`, preencher todos os campos (nome, e-mail, telefone, endereço, tipo de imóvel, área, finalidade, observações) e submeter. Verificar que o lead é criado com `source = "cadastro_residencia"` e que os detalhes do imóvel estão na mensagem ou no `source_detail`.
**Acceptance Scenarios**:
1. **Given** um proprietário na página `/cadastro-residencia`, **When** ele preenche todos os campos e clica em enviar, **Then** o formulário é submetido com sucesso e uma mensagem de confirmação é exibida informando que a equipe entrará em contato.
2. **Given** um proprietário na página `/cadastro-residencia`, **When** ele submete sem preencher campos obrigatórios (nome, e-mail, telefone, endereço, tipo e finalidade), **Then** os campos em falta são destacados com erros inline e o envio é bloqueado.
3. **Given** um proprietário que submeteu o formulário com sucesso, **When** o lead é registrado, **Then** `source` é `"cadastro_residencia"` e `source_detail` contém informação identificável do imóvel (ex.: tipo + finalidade ou endereço).
4. **Given** um proprietário na página `/cadastro-residencia`, **When** ele seleciona "Apartamento" como tipo e "Aluguel" como finalidade, **Then** esses valores são incluídos no registro enviado ao sistema.
5. **Given** um proprietário na página `/cadastro-residencia`, **When** o campo "Área m²" recebe um valor não numérico, **Then** o campo é destacado com erro de validação e o envio é bloqueado.
---
### User Story 3 — Visitante Envia Contato de Imóvel com Origem Rastreada (Priority: P2)
Um visitante interessado em um imóvel específico envia uma mensagem a partir da página de detalhes do imóvel. O sistema deve registrar automaticamente que o contato veio daquele imóvel específico, sem que o visitante precise fazer nada diferente.
**Why this priority**: O formulário de contato de imóvel já existe e funciona. A melhoria é transparente para o usuário e enriquece os dados para a equipe de vendas, que saberá exatamente qual imóvel gerou cada lead.
**Independent Test**: Acessar a página de detalhes de um imóvel (ex.: `/imoveis/apartamento-centro`), preencher e enviar o formulário de contato. Verificar que o lead criado possui `source = "imovel"` e `source_detail` com o título do imóvel.
**Acceptance Scenarios**:
1. **Given** um visitante na página de detalhes de um imóvel, **When** ele preenche e envia o formulário de contato, **Then** o lead é criado com `source = "imovel"` e `source_detail` contendo o título do imóvel.
2. **Given** um lead de imóvel criado com sucesso, **When** o administrador visualiza esse lead na central de leads, **Then** a coluna de origem exibe um badge "Imóvel" e o `source_detail` com o título do imóvel está visível.
3. **Given** um visitante na página de detalhes, **When** o formulário de contato é exibido, **Then** a experiência visual e os campos do formulário permanecem idênticos — nenhuma mudança visível para o usuário.
---
### User Story 4 — Administrador Visualiza e Filtra Leads na Central de Contatos (Priority: P1)
O administrador da imobiliária precisa de uma visão consolidada de todos os contatos recebidos — de qualquer origem — com a possibilidade de filtrar por tipo de origem para priorizar o atendimento.
**Why this priority**: Sem uma central unificada com filtros, a equipe não consegue distinguir leads de imóveis de propostas de parceria ou de proprietários querendo anunciar. O valor do rastreamento de origem só se materializa com uma interface de gestão adequada.
**Independent Test**: Acessar `/admin/leads` como administrador autenticado, verificar que leads das três origens aparecem na listagem. Aplicar filtro por "Cadastro de Residência" e verificar que apenas leads com `source = "cadastro_residencia"` são exibidos.
**Acceptance Scenarios**:
1. **Given** um administrador autenticado acessando `/admin/leads`, **When** a página carrega, **Then** todos os leads são listados em ordem cronológica decrescente com as colunas: origem (badge colorido), nome, e-mail, telefone, prévia da mensagem e data.
2. **Given** a listagem de leads, **When** o administrador seleciona o filtro "Imóvel", **Then** apenas leads com `source = "imovel"` são exibidos e o badge "Imóvel" aparece na coluna de origem de cada linha.
3. **Given** a listagem de leads, **When** o administrador seleciona o filtro "Contato", **Then** apenas leads com `source = "contato"` são exibidos.
4. **Given** a listagem de leads, **When** o administrador seleciona o filtro "Cadastro de Residência", **Then** apenas leads com `source = "cadastro_residencia"` são exibidos.
5. **Given** a listagem de leads com filtro "Imóvel" ativo, **When** há leads com `source_detail` preenchido, **Then** o título do imóvel é exibido como detalhe do badge ou em coluna/tooltip separado.
6. **Given** a listagem de leads, **When** o administrador seleciona "Todos", **Then** todos os leads de todas as origens são exibidos sem filtro.
7. **Given** um não administrador (sem autenticação ou sem permissão), **When** tenta acessar `/admin/leads`, **Then** é redirecionado para a tela de login ou recebe resposta de acesso negado.
8. **Given** a listagem de leads com muitos registros, **When** o limite de exibição por página é atingido, **Then** a navegação entre páginas ou carregamento adicional está disponível para acessar registros anteriores.
---
### User Story 5 — Visitante Navega para `/contato` e `/sobre` via Navbar (Priority: P3)
Um visitante que clica em "Contato" ou "Sobre" na barra de navegação é levado diretamente às páginas internas correspondentes, sem âncoras ou rolagem forçada.
**Why this priority**: Corrige um problema de usabilidade existente. A navbar já tem os links; é apenas necessário atualizar os destinos para rotas internas corretas.
**Independent Test**: Na homepage (ou em qualquer outra página), clicar em "Contato" na navbar e verificar que a rota muda para `/contato`. Clicar em "Sobre" e verificar que a rota muda para `/sobre`.
**Acceptance Scenarios**:
1. **Given** qualquer página do site com a navbar visível, **When** o visitante clica em "Contato", **Then** é navegado para `/contato` sem recarregar a página inteira.
2. **Given** qualquer página do site com a navbar visível, **When** o visitante clica em "Sobre", **Then** é navegado para `/sobre` sem recarregar a página inteira.
3. **Given** o visitante está em `/contato`, **When** a navbar é exibida, **Then** o link "Contato" aparece destacado como item ativo de navegação.
---
### Edge Cases
- O que acontece se o usuário submeter o formulário de `/contato` mais de uma vez seguida (duplo clique acidental)?
- O que acontece se o servidor retornar erro ao salvar o lead — o usuário recebe feedback adequado?
- O que acontece com leads criados antes da adição da coluna `source` — eles aparecem na listagem admin com origem "Desconhecida" ou ficam ocultos?
- Como o sistema se comporta quando `source_detail` está vazio para leads de imóvel (caso de falha na captura do título)?
- O que acontece se o campo "área m²" no formulário `/cadastro-residencia` receber um valor negativo?
- Como a paginação da listagem admin se comporta quando um filtro é aplicado e o número total de resultados muda?
- O que acontece se um visitante acessar `/contato` ou `/cadastro-residencia` em um dispositivo móvel — os formulários são responsivos?
---
## Requirements
### Functional Requirements
#### Grupo 1 — Rastreamento de Origem no Modelo de Lead
- **FR-001**: O modelo de lead DEVE ter um campo `source` que identifica a origem do contato com os valores possíveis: `"contato"`, `"imovel"` e `"cadastro_residencia"`.
- **FR-002**: O modelo de lead DEVE ter um campo `source_detail` opcional para registrar informação complementar da origem (ex.: título do imóvel, tipo + finalidade do imóvel anunciado).
- **FR-003**: Todos os novos leads criados a partir desta feature DEVEM ter o campo `source` preenchido automaticamente de acordo com a origem do formulário que os gerou.
- **FR-004**: Leads existentes criados antes desta feature (campo `source` ausente) NÃO DEVEM ser alterados retroativamente, mas DEVEM continuar acessíveis na listagem admin.
#### Grupo 2 — Página de Contato Geral (`/contato`)
- **FR-005**: O sistema DEVE disponibilizar a rota pública `/contato` com um formulário de contato geral acessível sem autenticação.
- **FR-006**: O formulário de contato DEVE conter os campos: nome (obrigatório), e-mail (obrigatório, formato válido), telefone (obrigatório), assunto (obrigatório, seleção entre: Informações, Anúncio, Parceria, Outro) e mensagem (obrigatória).
- **FR-007**: A submissão bem-sucedida do formulário DEVE criar um lead com `source = "contato"` e exibir uma mensagem de confirmação ao usuário.
- **FR-008**: O formulário DEVE validar todos os campos obrigatórios antes do envio e exibir mensagens de erro inline sem recarregar a página.
- **FR-009**: Após uma submissão bem-sucedida, o botão de envio DEVE ser desabilitado ou o formulário resetado para evitar envio duplicado acidental.
#### Grupo 3 — Página de Cadastro de Residência (`/cadastro-residencia`)
- **FR-010**: O sistema DEVE disponibilizar a rota pública `/cadastro-residencia` com formulário para proprietários interessados em anunciar um imóvel, acessível sem autenticação.
- **FR-011**: O formulário de cadastro DEVE conter os campos: nome (obrigatório), e-mail (obrigatório, formato válido), telefone (obrigatório), endereço (obrigatório), tipo de imóvel (obrigatório, seleção entre: Casa, Apartamento, Comercial), área em m² (opcional, numérico positivo), finalidade (obrigatório, seleção entre: Venda, Aluguel) e observações/mensagem (opcional).
- **FR-012**: A submissão bem-sucedida DEVE criar um lead com `source = "cadastro_residencia"`, com `source_detail` contendo identificação do imóvel (tipo, finalidade e/ou endereço), e exibir confirmação ao usuário.
- **FR-013**: O formulário DEVE validar todos os campos obrigatórios e o formato numérico do campo área antes do envio, com erros inline sem recarregar a página.
#### Grupo 4 — Atualização do Formulário de Contato de Imóvel
- **FR-014**: O formulário de contato existente na página de detalhes de imóvel DEVE passar `source = "imovel"` ao criar o lead.
- **FR-015**: O formulário de contato de imóvel DEVE passar `source_detail` com o título do imóvel ao criar o lead.
- **FR-016**: A aparência e os campos do formulário de contato de imóvel NÃO DEVEM ser alterados — a mudança é exclusivamente no dado enviado ao backend.
#### Grupo 5 — Central de Leads Admin (`/admin/leads`)
- **FR-017**: O sistema DEVE disponibilizar a rota protegida `/admin/leads` acessível apenas por usuários com perfil de administrador.
- **FR-018**: A listagem DEVE exibir todos os leads em ordem cronológica decrescente com as colunas: origem (badge colorido), nome, e-mail, telefone, prévia da mensagem (truncada) e data de criação.
- **FR-019**: Os badges de origem DEVEM ter cores distintas para cada valor de `source`: uma cor para `"contato"`, outra para `"imovel"` e outra para `"cadastro_residencia"`.
- **FR-020**: Para leads com `source = "imovel"` e `source_detail` preenchido, o título do imóvel DEVE ser exibido de forma associada ao badge (ex.: tooltip, sublinha ou coluna adicional).
- **FR-021**: A listagem DEVE oferecer um seletor de filtro por origem com as opções: Todos, Contato, Imóvel e Cadastro de Residência. A seleção de filtro DEVE atualizar a listagem sem recarregar a página.
- **FR-022**: Leads cujo campo `source` está vazio (criados antes desta feature) DEVEM ser exibidos na listagem com um badge ou label indicando origem "Desconhecida" ao selecionar filtro "Todos".
- **FR-023**: A listagem DEVE suportar paginação ou carregamento incremental para listas com grande volume de leads.
#### Grupo 6 — Navbar
- **FR-024**: O link "Contato" na navbar DEVE navegar para a rota interna `/contato`.
- **FR-025**: O link "Sobre" na navbar DEVE navegar para a rota interna `/sobre`.
- **FR-026**: O link ativo na navbar DEVE ser destacado visualmente quando a rota atual corresponder ao destino do link.
### Key Entities
- **Lead de Contato (ContactLead)**: Registro de interesse ou comunicação de um visitante. Atributos relevantes: identificador único, origem (`source`), detalhe de origem (`source_detail`), nome, e-mail, telefone, mensagem, data de criação. Pode ou não estar associado a um imóvel específico.
- **Origem (Source)**: Classificação do canal pelo qual o lead foi gerado. Valores fixos: `"contato"` (formulário geral), `"imovel"` (página de detalhes de imóvel), `"cadastro_residencia"` (formulário de anúncio de proprietário).
- **Detalhe de Origem (Source Detail)**: Informação livre associada à origem do lead. Para `"imovel"`: título do imóvel. Para `"cadastro_residencia"`: tipo, finalidade e/ou endereço do imóvel informado pelo proprietário.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Visitantes conseguem enviar um contato geral através de `/contato` em menos de 2 minutos a partir do primeiro acesso à página.
- **SC-002**: Proprietários conseguem submeter o formulário de cadastro de residência em `/cadastro-residencia` em menos de 3 minutos.
- **SC-003**: 100% dos leads criados a partir das páginas `/contato`, `/cadastro-residencia` e do formulário de imóvel contêm o campo `source` preenchido corretamente.
- **SC-004**: O administrador consegue filtrar leads por origem e visualizar apenas os registros relevantes em menos de 5 segundos após selecionar o filtro.
- **SC-005**: Os links "Contato" e "Sobre" da navbar levam às rotas corretas em 100% das navegações, sem âncoras intermediárias.
- **SC-006**: Zero leads duplicados são criados por submissões acidentais de duplo clique nos formulários de `/contato` e `/cadastro-residencia`.
- **SC-007**: A listagem de leads admin exibe corretamente leads de todas as origens, incluindo leads legados (sem `source`) sem erros ou omissões.
---
## Assumptions
- O modelo `ContactLead` já existe no banco de dados com as colunas `id`, `property_id`, `name`, `email`, `phone`, `message` e `created_at`; as colunas `source` e `source_detail` serão adicionadas via migration Alembic.
- A coluna `source` aceita `NULL` para registros existentes criados antes da migration (compatibilidade retroativa sem valor padrão obrigatório).
- O assunto selecionado no formulário `/contato` é incluído no campo `message` (compondo a mensagem) ou em um campo auxiliar — não requer nova coluna no modelo.
- Os dados do formulário `/cadastro-residencia` são registrados como um único lead usando os campos existentes (`name`, `email`, `phone`, `message`) com detalhes do imóvel concatenados na mensagem e `source_detail` com identificação resumida.
- A autenticação de administrador já existe no sistema; `/admin/leads` usa o mesmo mecanismo de proteção das demais rotas admin.
- A rota `GET /admin/leads` existente será estendida para aceitar o parâmetro de filtro `source` e retornar leads com os novos campos; não é criada uma nova rota separada.
- A paginação da listagem admin utiliza paginação simples por offset/limit ou infinite scroll — a escolha de implementação é deixada para a fase de planejamento.
- Não há envio de e-mail de notificação à equipe da imobiliária como parte desta feature (pode ser adicionado em feature futura).
- As páginas `/contato` e `/cadastro-residencia` são acessíveis publicamente sem autenticação, assim como as demais páginas públicas do site.
- A navbar já possui os itens "Sobre" e "Contato" renderizados; apenas os `href`/destinos de roteamento serão atualizados.

View file

@ -0,0 +1,206 @@
# Tasks: Central de Contatos com Rastreamento de Origem
**Input**: Design documents from `/specs/026-central-contatos/`
**Prerequisites**: spec.md ✅ · contexto técnico do autor ✅
**Branch**: `026-central-contatos`
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: pode ser executada em paralelo (arquivos distintos, sem dependência de tarefa incompleta)
- **[Story]**: a qual user story pertence (`US1`, `US2`, `US3`, `US4`, `US5`)
- Caminhos exatos incluídos em cada tarefa
---
## Phase 1: Foundational — Modelo e Esquemas de Lead
**Purpose**: Adicionar os campos `source` e `source_detail` ao banco e às camadas Python. Este é o pré-requisito de todas as user stories — nenhuma pode ser implementada sem estes dados disponíveis.
**⚠️ BLOQUEANTE para todas as US**: nenhuma tarefa de US1US5 pode começar até T003 estar completo.
- [ ] T001 Criar migration Alembic para adicionar colunas `source VARCHAR(100) NULL` e `source_detail VARCHAR(255) NULL` à tabela `contact_leads` em `backend/migrations/versions/<timestamp>_add_source_to_contact_leads.py`
- **Detalhes**: usar `op.add_column('contact_leads', sa.Column('source', sa.String(100), nullable=True))` e `op.add_column('contact_leads', sa.Column('source_detail', sa.String(255), nullable=True))`; `downgrade` deve executar `op.drop_column` nas duas colunas; gerar com `alembic revision --autogenerate` e revisar o arquivo gerado.
- **Done**: `alembic upgrade head` executa sem erro; `\d contact_leads` mostra colunas `source` e `source_detail` como nullable; `alembic downgrade -1` remove as colunas sem erro.
- [ ] T002 Adicionar atributos `source` e `source_detail` ao modelo `ContactLead` em `backend/app/models/lead.py`
- **Detalhes**: adicionar `source = db.Column(db.String(100), nullable=True)` e `source_detail = db.Column(db.String(255), nullable=True)` após a coluna `message` existente; sem quebrar campos existentes (`id`, `property_id`, `name`, `email`, `phone`, `message`, `created_at`).
- **Done**: `ContactLead()` instancia sem erro; os dois campos são `None` por default; SQLAlchemy reflete corretamente no ORM após migration aplicada.
- [ ] T003 Atualizar schemas de lead em `backend/app/schemas/lead.py` para aceitar `source` e `source_detail` opcionais
- **Detalhes**: em `ContactLeadIn` (schema de entrada), adicionar `source: Optional[str] = None` e `source_detail: Optional[str] = None`; em `ContactLeadCreatedOut` (schema de saída), adicionar `source: Optional[str] = None` e `source_detail: Optional[str] = None`; importar `Optional` de `typing` se ainda não importado.
- **Done**: `ContactLeadIn(name="X", email="x@x.com", phone="99", message="msg")` valida sem erro (campos opcionais ausentes); `ContactLeadIn(..., source="imovel", source_detail="Apto Centro")` também valida; schema de saída serializa os dois campos.
**Checkpoint Phase 1**: `alembic upgrade head` OK; modelo ORM com os dois novos campos; schemas Pydantic validando opcionalmente. Base para todas as demais fases.
---
## Phase 2: Foundational — Endpoints Backend
**Purpose**: Expor o endpoint público de contato geral (`POST /contact`) e atualizar o roteamento do admin para filtrar por `source`. Estes dois endpoints são pré-requisitos de US1 e US4, respectivamente, e podem ser implementados em paralelo após Phase 1.
- [ ] T004 Criar endpoint `POST /api/v1/contact` (contato geral sem property) em `backend/app/routes/properties.py` ou em novo arquivo `backend/app/routes/contact.py`
- **Detalhes**: aceitar body `ContactLeadIn` via `request.get_json()`; validar com Pydantic; criar `ContactLead(property_id=None, source="contato", source_detail=None, **data.model_dump(exclude={"source","source_detail"}), name=data.name, email=data.email, phone=data.phone, message=data.message)`; persistir com `db.session.add` + `db.session.commit()`; retornar `ContactLeadCreatedOut.model_validate(lead).model_dump()` com status 201; registrar blueprint em `backend/app/__init__.py` se novo arquivo criado.
- **Done**: `POST /api/v1/contact` com payload válido retorna 201 e `id` do lead criado; lead salvo no banco com `source="contato"` e `property_id=NULL`; payload sem `name` retorna 422.
- [ ] T005 [P] Atualizar rota `GET /admin/leads` em `backend/app/routes/admin.py` para filtrar por `?source=`
- **Detalhes**: após o filtro existente `?property_id`, adicionar `source = request.args.get("source")`; se `source` não é None nem string vazia, aplicar `query = query.filter(ContactLead.source == source)`; manter retorno de todos os leads quando `source` não é passado; incluir os campos `source` e `source_detail` na serialização de saída (via schema ou `lead.__dict__`).
- **Done**: `GET /admin/leads` sem parâmetros retorna todos os leads; `GET /admin/leads?source=imovel` retorna somente leads com `source="imovel"`; `GET /admin/leads?source=contato` retorna somente leads com `source="contato"`; leads com `source=NULL` aparecem em "Todos" mas não nos filtros específicos.
**Checkpoint Phase 2**: curl `POST /api/v1/contact` retorna 201 ✓; curl `GET /admin/leads?source=contato` filtra corretamente ✓.
---
## Phase 3: US1 — Página de Contato Geral `/contato` (Priority: P1) 🎯 MVP
**Goal**: Disponibilizar página pública `/contato` com formulário funcional que cria leads com `source = "contato"`.
**Independent Test**: Acessar `/contato` sem autenticação, preencher nome, e-mail, telefone, assunto e mensagem e submeter. Verificar mensagem de confirmação na tela e lead salvo no banco com `source = "contato"`.
**Dependências**: T004 (endpoint `POST /api/v1/contact`) deve estar completo.
- [ ] T006 [US1] Criar função `contactGeneral(data)` em `frontend/src/services/properties.ts`
- **Detalhes**: exportar `async function contactGeneral(data: { name: string; email: string; phone: string; subject: string; message: string }): Promise<void>`; fazer `POST /api/v1/contact` com axios passando `{ ...data, source: "contato" }`; lançar erro em caso de resposta não-2xx.
- **Done**: função exportada e tipada; chama o endpoint correto com `source: "contato"` no payload.
- [ ] T007 [US1] Criar `frontend/src/pages/ContactPage.tsx`
- **Detalhes**: campos controlados: `name` (text, obrigatório), `email` (email, obrigatório, validar formato com regex), `phone` (tel, obrigatório), `subject` (select com opções: "Informações", "Anúncio", "Parceria", "Outro", obrigatório), `message` (textarea, obrigatório); estado `loading: boolean`, `success: boolean`, `errors: Record<string, string>`; validação client-side antes do submit (todos obrigatórios + formato de e-mail); ao submeter, chamar `contactGeneral(data)`; em sucesso, exibir mensagem de confirmação inline e desabilitar/resetar o formulário; em erro do servidor, exibir alerta de erro genérico; estilizar com Tailwind CSS consistente com o restante do projeto.
- **Done**: formulário renderiza; validação destaca campos obrigatórios ausentes sem submeter; e-mail inválido exibe erro inline; submissão bem-sucedida mostra confirmação e bloqueia re-envio; submissão com erro de servidor mostra feedback de erro.
- [ ] T008 [US1] Registrar rota `/contato` em `frontend/src/App.tsx`
- **Detalhes**: importar `ContactPage` de `./pages/ContactPage`; adicionar `<Route path="/contato" element={<ContactPage />} />` dentro do bloco de rotas públicas existente (dentro de `<Routes>`); não alterar nenhuma outra rota.
- **Done**: navegar para `/contato` renderiza `ContactPage`; rotas existentes não quebram.
**Checkpoint US1**: Acessar `/contato` → página renderiza ✓. Submeter com todos os campos → confirmação aparece ✓. Submeter sem nome → erro inline ✓. Lead salvo com `source="contato"` ✓.
---
## Phase 4: US2 — Página de Cadastro de Residência `/cadastro-residencia` (Priority: P1)
**Goal**: Disponibilizar página pública `/cadastro-residencia` para proprietários interessados em anunciar imóvel, criando lead com `source = "cadastro_residencia"`.
**Independent Test**: Acessar `/cadastro-residencia`, preencher todos os campos obrigatórios (nome, e-mail, telefone, endereço, tipo, finalidade) e submeter. Verificar lead criado com `source = "cadastro_residencia"` e `source_detail` contendo identificação do imóvel.
**Dependências**: T004 (endpoint `POST /api/v1/contact`) deve estar completo.
- [ ] T009 [US2] Criar `frontend/src/pages/CadastroResidenciaPage.tsx`
- **Detalhes**: campos controlados: `name` (text, obrigatório), `email` (email, obrigatório, validar formato), `phone` (tel, obrigatório), `address` (text, obrigatório), `propertyType` (select: "Casa", "Apartamento", "Comercial", obrigatório), `area` (number opcional, validar que é positivo quando preenchido), `purpose` (select: "Venda", "Aluguel", obrigatório), `notes` (textarea, opcional); estado `loading`, `success`, `errors`; ao submeter, construir `source_detail` como `"${propertyType} • ${purpose} • ${address}"` e enviar para `POST /api/v1/contact` com `{ name, email, phone, message: notes || "(sem observações)", source: "cadastro_residencia", source_detail }`; exibir confirmação em sucesso; estilizar com Tailwind CSS.
- **Done**: formulário renderiza todos os campos; campos obrigatórios ausentes são destacados; área não-numérica ou negativa exibe erro inline; submissão cria lead com `source="cadastro_residencia"` e `source_detail` identificável; confirmação aparece após sucesso.
- [ ] T010 [US2] Registrar rota `/cadastro-residencia` em `frontend/src/App.tsx`
- **Detalhes**: importar `CadastroResidenciaPage` de `./pages/CadastroResidenciaPage`; adicionar `<Route path="/cadastro-residencia" element={<CadastroResidenciaPage />} />` nas rotas públicas; não alterar outras rotas.
- **Done**: navegar para `/cadastro-residencia` renderiza `CadastroResidenciaPage`; rotas existentes não quebram.
**Checkpoint US2**: Acessar `/cadastro-residencia` → página renderiza ✓. Submeter com todos os campos → lead criado com `source="cadastro_residencia"` ✓. Área negativa → erro inline ✓.
---
## Phase 5: US4 — Central de Leads no Painel Admin (Priority: P1)
**Goal**: Página `/admin/leads` com listagem de todos os leads ordenada por data decrescente e filtro por origem (badge colorido por source).
**Independent Test**: Autenticar como admin, acessar `/admin/leads`, verificar leads das três origens listados. Aplicar filtro "Cadastro de Residência" e confirmar que apenas `source = "cadastro_residencia"` aparece.
**Dependências**: T005 (`GET /admin/leads?source=`) deve estar completo.
- [ ] T011 [US4] Criar `frontend/src/pages/admin/AdminLeadsPage.tsx`
- **Detalhes**: ao montar, buscar `GET /api/v1/admin/leads` (com token de autenticação via axios interceptor existente); estado `leads`, `loading`, `error`, `sourceFilter: string` (default `""`); quando `sourceFilter` não vazio, refazer fetch com `?source=${sourceFilter}`; renderizar barra de filtros com botões/tabs: "Todos", "Contato" (`source=contato`), "Imóvel" (`source=imovel`), "Cadastro de Residência" (`source=cadastro_residencia`); tabela com colunas: Origem (badge colorido por source — contato: azul, imovel: verde, cadastro_residencia: laranja, null/desconhecido: cinza), Nome, E-mail, Telefone, Prévia da mensagem (truncada em 60 chars), Data (formatada `dd/MM/yyyy`); badge "Imóvel" exibe `source_detail` como subtitle ou tooltip; ordenar por `created_at DESC`; exibir estado vazio ("Nenhum lead encontrado") quando lista vazia; suportar paginação básica (se a listagem retornar campo de total, exibir link "carregar mais" ou navegação por páginas).
- **Done**: página renderiza leads de todas as origens; filtro por source recarrega a lista; badge colorido por origem; `source_detail` visível para leads de imóvel; data formatada; estado de loading e erro tratados.
- [ ] T012 [US4] Adicionar item "Leads" ao menu lateral em `frontend/src/layouts/AdminLayout.tsx`
- **Detalhes**: localizar o array/lista de `navLinks` ou itens de menu do `AdminLayout`; adicionar entrada `{ label: "Leads", path: "/admin/leads" }` (ou equivalente JSX `<NavLink to="/admin/leads">Leads</NavLink>`) após o item existente que melhor se encaixa na ordem do menu (ex.: após "Imóveis" ou "Configurações"); não remover nem reordenar itens existentes.
- **Done**: menu lateral exibe item "Leads"; clicar navega para `/admin/leads`; link ativo é destacado pelo mecanismo existente.
- [ ] T013 [US4] Registrar rota `/admin/leads` em `frontend/src/App.tsx`
- **Detalhes**: importar `AdminLeadsPage` de `./pages/admin/AdminLeadsPage`; adicionar `<Route path="/admin/leads" element={<AdminLeadsPage />} />` dentro do bloco de rotas protegidas de admin (dentro do layout de admin existente); não alterar outras rotas.
- **Done**: navegar para `/admin/leads` como admin autenticado renderiza `AdminLeadsPage` dentro do `AdminLayout`; não autenticado redireciona para login.
**Checkpoint US4**: Acessar `/admin/leads` autenticado → listagem completa ✓. Filtrar por "Imóvel" → somente `source="imovel"` ✓. Badge colorido por origem ✓.
---
## Phase 6: US3 — Rastreamento de Origem no Formulário de Imóvel (Priority: P2)
**Goal**: O formulário de contato existente na página de detalhes do imóvel passa automaticamente `source = "imovel"` e `source_detail = property.title` sem alterar a experiência visual do usuário.
**Independent Test**: Acessar página de detalhes de um imóvel, enviar formulário de contato. Verificar no banco que o lead criado tem `source = "imovel"` e `source_detail` com o título do imóvel.
**Dependências**: T003 (schemas atualizados), T001/T002 (migration e modelo), mais T004 não é necessário — a rota de imóvel já existe. As tasks de frontend dependem de T003.
- [ ] T014 [US3] Atualizar rota `POST /properties/<slug>/contact` em `backend/app/routes/properties.py` para salvar `source` e `source_detail`
- **Detalhes**: após criar o objeto `data = ContactLeadIn(**request.get_json())`, ao instanciar `ContactLead`, definir `source = data.source or "imovel"` e `source_detail = data.source_detail`; o campo `property_id` já é definido pela slug da rota; o resto do handler permanece idêntico.
- **Done**: `POST /properties/<slug>/contact` salva `source="imovel"` quando o frontend não passa `source`; quando o frontend passa `source`, usa o valor recebido; `property_id` continua sendo preenchido pela slug.
- [ ] T015 [P] [US3] Atualizar a função `contactProperty` em `frontend/src/services/properties.ts` para aceitar e enviar `source` e `source_detail`
- **Detalhes**: adicionar `source?: string` e `source_detail?: string` ao tipo do parâmetro `data` (ou ao tipo `ContactData` se existir); incluir esses campos no payload da chamada axios existente: `{ ...data, source, source_detail }`.
- **Done**: `contactProperty(slug, { name, email, phone, message, source: "imovel", source_detail: "Título" })` envia o payload completo; chamadas sem `source`/`source_detail` continuam funcionando (campos opcionais).
- [ ] T016 [US3] Atualizar `frontend/src/pages/PropertyDetailPage.tsx` para passar `source` e `source_detail` ao chamar `contactProperty`
- **Detalhes**: localizar a chamada existente a `contactProperty(slug, data)` no handler de submit do formulário; adicionar `source: "imovel"` e `source_detail: property?.title ?? ""` ao objeto `data` passado; nenhuma mudança visual ou nos campos do formulário.
- **Done**: ao submeter o formulário de contato em `/imoveis/<slug>`, o lead criado tem `source="imovel"` e `source_detail` com o título do imóvel; a aparência do formulário é idêntica.
**Checkpoint US3**: Enviar formulário de contato em `/imoveis/<slug>` → lead com `source="imovel"` e `source_detail=título` no banco ✓. Formulário sem mudanças visuais ✓.
---
## Phase 7: US5 — Correção dos Links da Navbar (Priority: P3)
**Goal**: Os links "Contato" e "Sobre" da navbar navegam para rotas internas `/contato` e `/sobre` em vez de âncoras na homepage.
**Independent Test**: Em qualquer página, clicar "Contato" → URL muda para `/contato`. Clicar "Sobre" → URL muda para `/sobre`. Sem reload da página inteira.
**Dependências**: nenhuma — tarefa completamente independente; pode ser executada a qualquer momento após a branch ser criada.
- [ ] T017 [P] [US5] Corrigir links "Sobre" e "Contato" em `frontend/src/components/Navbar.tsx`
- **Detalhes**: localizar o array `navLinks` (ou equivalente); alterar a entrada com label/text "Sobre" de `href="/#sobre"` (ou `to="/#sobre"`) para `to="/sobre"`; alterar a entrada "Contato" de `href="/#contato"` (ou `to="/#contato"`) para `to="/contato"`; se os links usam tag `<a>`, converter para `<Link>` ou `<NavLink>` do react-router-dom para navegação client-side.
- **Done**: clicar "Contato" na navbar navega para `/contato` sem reload; clicar "Sobre" navega para `/sobre` sem reload; o link ativo é destacado pelo mecanismo existente do NavLink.
**Checkpoint US5**: Navbar em qualquer página: "Contato" → `/contato` ✓. "Sobre" → `/sobre` ✓. Navegação client-side (sem reload) ✓.
---
## Polish & Cross-Cutting
**Purpose**: Ajustes finais de UX e consistência que dependem de todas as user stories anteriores estarem completas.
- [ ] T018 Verificar responsividade dos formulários de `/contato` e `/cadastro-residencia` em viewport mobile (< 768px) ajustar classes Tailwind se necessário em `frontend/src/pages/ContactPage.tsx` e `frontend/src/pages/CadastroResidenciaPage.tsx`
- **Done**: formulários renderizam sem overflow horizontal em 375px de largura; inputs e botões têm tamanho mínimo de toque adequado.
- [ ] T019 [P] Garantir que leads com `source = NULL` (criados antes da feature) aparecem na listagem admin sem errors em `frontend/src/pages/admin/AdminLeadsPage.tsx`
- **Detalhes**: o badge de origem deve exibir "Desconhecida" (cinza) quando `source` é `null` ou `undefined`; nenhum crash ou `undefined` visível na UI.
- **Done**: leads sem `source` exibem badge cinza "Desconhecida"; filtros "Contato", "Imóvel" e "Cadastro de Residência" não incluem esses leads; filtro "Todos" os inclui.
---
## Dependency Graph
```
T001 → T002 → T003 ──┬──→ T004 ──→ T006 → T007 → T008 [US1]
│ └──→ T009 → T010 [US2]
├──→ T005 [US4 backend]
├──→ T014 [US3 backend]
└──→ T015 → T016 [US3 frontend]
T005 ──→ T011 → T012 → T013 [US4 frontend]
T017 [US5 — independente]
T018, T019 ── aguardam todas as US [Polish]
```
## Parallel Execution
**Após T003 completo**, as seguintes trilhas podem avançar em paralelo:
| Trilha A (US1) | Trilha B (US2) | Trilha C (US4 backend) | Trilha D (US3) | Trilha E (US5) |
|---|---|---|---|---|
| T004 → T006 → T007 → T008 | T004 → T009 → T010 | T005 | T014 + T015 → T016 | T017 |
> T004 é compartilhado entre US1 e US2 — completar antes de iniciar as duas trilhas.
## Implementation Strategy
**MVP mínimo** (US1 + US4): Phase 1 → Phase 2 → Phase 3 → Phase 5. Resultado: formulário `/contato` funcional + admin pode visualizar e filtrar leads por origem.
**Incremento 2** (+ US2): adicionar Phase 4. Proprietários já podem cadastrar imóveis para anúncio.
**Incremento 3** (+ US3): adicionar Phase 6. Rastreamento completo também para contatos originados de imóveis específicos.
**Incremento 4** (+ US5 + Polish): Phase 7 + Polish. Navbar corrigida e ajustes de responsividade.

View file

@ -0,0 +1,35 @@
# Specification Quality Checklist: Configuração da Página de Contato (Admin)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec aprovada sem necessidade de clarificações — todos os campos, escopo, padrão de autenticação e comportamento de fallback foram especificados pelo solicitante.
- Pronta para `/speckit.plan`.

View file

@ -0,0 +1,151 @@
# Feature Specification: Configuração da Página de Contato (Admin)
**Feature Branch**: `027-config-pagina-contato`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
A página `/contato` do site exibe informações institucionais de contato — endereço, telefone, e-mail e horário de atendimento — atualmente fixadas no código-fonte do frontend. Qualquer alteração nessas informações exige um deploy de código, o que cria dependência técnica para uma tarefa puramente operacional.
Esta spec cobre a criação de uma configuração persistida em banco de dados que o administrador pode editar pelo painel admin, tornando o conteúdo da página de contato dinâmico e gerenciável sem necessidade de deploy.
O padrão adotado é o mesmo já utilizado para a `HomepageConfig`: tabela singleton (sempre `id = 1`), endpoint público de leitura e endpoint protegido de escrita acessível apenas por administradores autenticados.
---
## User Scenarios & Testing
### User Story 1 — Administrador Atualiza as Informações de Contato (Priority: P1)
O administrador da imobiliária precisa atualizar o endereço, telefone, e-mail ou horário de atendimento sem depender de um desenvolvedor ou deploy de código.
**Why this priority**: É o núcleo da feature. Sem a capacidade de edição pelo admin, todo o restante não tem valor.
**Independent Test**: Autenticar como administrador, acessar a página de configuração de contato no painel admin (`/admin/contact-config`), alterar o campo de telefone para um novo valor e salvar. Verificar que a resposta da API pública `GET /api/v1/contact-config` retorna o novo valor e que a página `/contato` do site exibe o telefone atualizado após recarregar.
**Acceptance Scenarios**:
1. **Given** um administrador autenticado no painel admin, **When** ele acessa `/admin/contact-config`, **Then** um formulário é exibido com os valores atuais dos campos de endereço, telefone, e-mail e horário de atendimento já preenchidos.
2. **Given** o formulário preenchido com os valores atuais, **When** o admin altera o campo de telefone e clica em "Salvar", **Then** as alterações são persistidas e uma mensagem de sucesso é exibida na tela.
3. **Given** que a configuração foi salva com sucesso, **When** a API pública de configuração de contato é consultada, **Then** ela retorna os novos valores imediatamente, sem necessidade de reiniciar o sistema.
4. **Given** um administrador autenticado, **When** ele tenta salvar o formulário com o campo de e-mail em branco, **Then** o campo é destacado com erro de validação e o envio é bloqueado.
5. **Given** um administrador autenticado, **When** ele tenta salvar com um endereço de e-mail em formato inválido, **Then** o campo é destacado com erro de validação antes do envio ser processado.
---
### User Story 2 — Página de Contato Exibe Informações Dinâmicas (Priority: P1)
Um visitante do site acessa a página `/contato` e vê as informações de contato mais recentes cadastradas pelo administrador, sem nenhuma interação adicional necessária.
**Why this priority**: É o consumidor final da configuração. Sem a integração com a API, a feature não entrega valor ao visitante nem à imobiliária.
**Independent Test**: Com uma configuração de contato salva via painel admin, acessar `/contato` sem autenticação e verificar que o endereço, telefone, e-mail e horário de atendimento exibidos correspondem exatamente aos valores salvos — e não aos dados anteriormente fixados no código.
**Acceptance Scenarios**:
1. **Given** uma configuração de contato salva no sistema, **When** qualquer visitante acessa `/contato`, **Then** a página exibe o endereço (rua, bairro/cidade e CEP), telefone, e-mail e horário de atendimento provenientes da API.
2. **Given** que o administrador atualizou o horário de atendimento, **When** um visitante recarrega `/contato`, **Then** o novo horário é exibido imediatamente.
3. **Given** que a API de configuração de contato está indisponível, **When** um visitante acessa `/contato`, **Then** a página exibe um estado de carregamento ou uma mensagem informativa, sem exibir dados desatualizados ou causar erro de renderização crítico.
4. **Given** um visitante não autenticado, **When** ele acessa a rota pública `GET /api/v1/contact-config` diretamente, **Then** a resposta retorna os dados de configuração sem exigir autenticação.
---
### User Story 3 — Proteção do Endpoint de Edição (Priority: P1)
Apenas administradores autenticados podem alterar a configuração de contato. Tentativas não autorizadas são bloqueadas.
**Why this priority**: Segurança é requisito não-negociável para qualquer endpoint de escrita no painel admin. O impacto de um acesso não autorizado incluiria exibição de informações falsas para todos os visitantes do site.
**Independent Test**: Enviar uma requisição `PUT /admin/contact-config` sem token de autenticação (ou com token de usuário comum) e verificar que a resposta é HTTP 401 ou 403. Verificar também que os dados salvos no banco não foram alterados.
**Acceptance Scenarios**:
1. **Given** uma requisição `PUT /admin/contact-config` sem token de autenticação, **When** a requisição é processada, **Then** o sistema responde com erro de acesso não autorizado (HTTP 401) e não altera nenhum dado.
2. **Given** uma requisição `PUT /admin/contact-config` com um token de usuário comum (não administrador), **When** a requisição é processada, **Then** o sistema responde com erro de permissão insuficiente (HTTP 403) e não altera nenhum dado.
3. **Given** uma requisição `PUT /admin/contact-config` com token de administrador válido, **When** os dados enviados são válidos, **Then** a configuração é atualizada e o sistema retorna os dados atualizados (HTTP 200).
---
### Edge Cases
- O que acontece se a tabela `contact_config` estiver vazia (nenhuma configuração foi salva ainda)? O endpoint público deve retornar valores padrão pré-populados ou um erro?
- Como a página `/contato` se comporta durante o carregamento inicial enquanto aguarda a resposta da API?
- O que acontece se o campo `business_hours` for enviado com um texto excessivamente longo?
- Como o formulário admin lida com falha de rede ao tentar salvar — o usuário perde as alterações não salvas?
- O que acontece se dois administradores tentarem salvar a configuração simultaneamente?
---
## Requirements
### Functional Requirements
#### Grupo 1 — Dados e Persistência
- **FR-001**: O sistema DEVE armazenar as informações de configuração de contato em uma tabela singleton de forma que exista sempre exatamente um registro com `id = 1`, criado automaticamente na primeira leitura ou escrita caso ainda não exista.
- **FR-002**: A configuração DEVE incluir os seguintes campos: logradouro do endereço, complemento de bairro/cidade, CEP, telefone, e-mail e horário de atendimento (texto livre multilinha).
- **FR-003**: Todos os campos DEVEM ser obrigatórios — nenhum pode ser salvo como nulo ou vazio.
- **FR-004**: O campo de e-mail DEVE ser validado quanto ao formato antes de ser persistido.
- **FR-005**: O sistema DEVE registrar automaticamente a data e hora da última atualização da configuração.
#### Grupo 2 — API Pública de Leitura
- **FR-006**: O sistema DEVE disponibilizar um endpoint público de leitura de configuração de contato acessível sem autenticação.
- **FR-007**: O endpoint público DEVE retornar todos os campos da configuração em formato estruturado (um objeto com os campos nomeados).
- **FR-008**: O endpoint público DEVE retornar os valores padrão (correspondentes aos dados atualmente fixados no código) caso nenhuma configuração tenha sido salva ainda, em vez de retornar erro.
#### Grupo 3 — API Protegida de Escrita
- **FR-009**: O sistema DEVE disponibilizar um endpoint protegido de atualização de configuração de contato acessível apenas por administradores autenticados.
- **FR-010**: O endpoint protegido DEVE rejeitar requisições sem token de autenticação válido com HTTP 401.
- **FR-011**: O endpoint protegido DEVE rejeitar tokens de usuários com perfil diferente de administrador com HTTP 403.
- **FR-012**: O endpoint protegido DEVE validar todos os campos recebidos antes de persistir e retornar erros de validação específicos por campo em caso de dados inválidos (HTTP 422).
- **FR-013**: Após persistência bem-sucedida, o endpoint DEVE retornar os dados atualizados incluindo a data de última atualização.
#### Grupo 4 — Interface Admin
- **FR-014**: O painel admin DEVE disponibilizar uma página de edição de configuração de contato (`/admin/contact-config`) acessível apenas a administradores autenticados.
- **FR-015**: A página admin DEVE carregar automaticamente os valores atuais da configuração ao ser aberta e pré-preencher o formulário.
- **FR-016**: O formulário DEVE conter campos de texto para logradouro, bairro/cidade, CEP, telefone, e-mail e uma área de texto para horário de atendimento.
- **FR-017**: O formulário DEVE exibir erros de validação inline por campo antes de tentar salvar no servidor, quando possível (ex.: e-mail com formato inválido, campo obrigatório vazio).
- **FR-018**: O formulário DEVE exibir uma notificação de sucesso após salvar com êxito e uma notificação de erro em caso de falha na requisição ao servidor.
- **FR-019**: O botão de salvar DEVE ser desabilitado enquanto a requisição de salvamento estiver em andamento, para evitar submissões duplicadas.
#### Grupo 5 — Página Pública de Contato
- **FR-020**: A página `/contato` DEVE buscar as informações de contato da API pública em vez de usar valores fixados no código.
- **FR-021**: A página `/contato` DEVE exibir um indicador de carregamento enquanto aguarda a resposta da API.
- **FR-022**: A página `/contato` DEVE continuar renderizando normalmente em caso de falha na API, exibindo uma mensagem informativa no lugar das informações de contato.
- **FR-023**: A estrutura visual e o layout da página `/contato` NÃO DEVEM ser alterados por esta feature — apenas a origem dos dados muda.
### Key Entities
- **ContactConfig**: Registro singleton de configuração de contato da imobiliária. Atributos: logradouro, bairro/cidade, CEP, telefone, e-mail, horário de atendimento (texto multilinha), data de última atualização. Relacionamentos: nenhum — é uma entidade independente de configuração.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: O administrador consegue atualizar qualquer campo de contato em menos de 1 minuto, do acesso à página admin até a confirmação de salvamento.
- **SC-002**: A página `/contato` reflete as alterações feitas pelo admin imediatamente após o recarregamento da página, sem necessidade de nenhuma intervenção técnica.
- **SC-003**: 100% das tentativas de acesso não autenticado ao endpoint de escrita são bloqueadas com resposta de erro apropriada.
- **SC-004**: A página `/contato` permanece funcional e renderizável mesmo quando a API de configuração retorna erro, sem quebrar a experiência do visitante.
- **SC-005**: Nenhum campo de configuração de contato permanece fixado no código-fonte do frontend após a conclusão da feature.
---
## Assumptions
- O padrão singleton (`get_or_create` com `id = 1`) já é conhecido e usado na codebase (`HomepageConfig`); esta feature segue exatamente o mesmo padrão.
- Os valores padrão (usados como fallback quando nenhuma configuração existe) são os atualmente hardcoded na página `/contato`: endereço "Rua das Imobiliárias, 123 / Centro — São Paulo, SP / CEP 01000-000", telefone "(11) 99999-0000", e-mail "contato@imobiliariahub.com.br" e horário conforme texto atual.
- O mecanismo de autenticação e autorização de admin (`require_admin`) já existe e será reutilizado sem modificações.
- Não há necessidade de histórico de versões da configuração — apenas o valor atual importa.
- O campo `business_hours` é texto livre; a formatação de exibição (ex.: quebras de linha) é responsabilidade do componente de apresentação, não da API.
- Esta feature não altera o design, layout ou demais seções da página `/contato` — apenas substitui os dados hardcoded por dados dinâmicos.
- A migração de banco de dados para criar a tabela `contact_config` será gerada via Alembic, seguindo o padrão já adotado no projeto.
- A seed inicial que popula a tabela com os valores padrão é opcional — o comportamento de fallback no endpoint público é suficiente para o primeiro acesso.

View file

@ -0,0 +1,404 @@
# Tasks: Feature 027 — Configuração da Página de Contato (Admin)
**Branch**: `027-config-pagina-contato`
**Spec**: `specs/027-config-pagina-contato/spec.md`
**Última migration**: `g1h2i3j4k5l6_add_source_to_contact_leads.py`
---
## Fase 1 — Foundational: Backend Core (Pré-requisito para todos os user stories)
> **Objetivo**: Criar a tabela `contact_config`, o modelo ORM, os schemas Pydantic e o
> endpoint público de leitura. Nenhum user story pode ser implementado antes desta fase.
>
> **⚠️ CRÍTICO**: Concluir inteiramente antes de iniciar as fases 2 e 3.
- [ ] T001 Gerar migration Alembic para criar tabela `contact_config` com INSERT inicial em `backend/migrations/versions/h2i3j4k5l6m7_add_contact_config.py`
**Comando para gerar a migration** (executar de dentro do container ou com `.venv`):
```bash
flask --app run:app db revision --autogenerate -m "add_contact_config"
```
A migration deve:
1. Criar a tabela com os campos abaixo:
```python
op.create_table(
"contact_config",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("address_street", sa.String(200), nullable=False),
sa.Column("address_neighborhood_city", sa.String(200), nullable=False),
sa.Column("address_zip", sa.String(20), nullable=False),
sa.Column("phone", sa.String(30), nullable=False),
sa.Column("email", sa.String(254), nullable=False),
sa.Column("business_hours", sa.Text, nullable=False),
sa.Column("updated_at", sa.DateTime, nullable=False,
server_default=sa.func.now()),
)
```
2. Inserir o registro singleton com os valores atualmente hardcoded:
```python
op.execute("""
INSERT INTO contact_config
(id, address_street, address_neighborhood_city, address_zip,
phone, email, business_hours, updated_at)
VALUES
(1, 'Rua das Imobiliárias, 123',
'Centro — São Paulo, SP',
'CEP 01000-000',
'(11) 99999-0000',
'contato@imobiliariahub.com.br',
'Segunda a Sexta: 9h às 18h\nSábado: 9h às 13h',
NOW())
""")
```
- [ ] T002 Criar modelo `ContactConfig` em `backend/app/models/contact_config.py` seguindo o padrão de `HomepageConfig`
```python
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=False)
address_neighborhood_city = db.Column(db.String(200), nullable=False)
address_zip = db.Column(db.String(20), nullable=False)
phone = db.Column(db.String(30), nullable=False)
email = db.Column(db.String(254), nullable=False)
business_hours = db.Column(db.Text, nullable=False)
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!r}>"
```
- [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/contact_config.py` seguindo o padrão de `HomepageConfigOut`/`HomepageConfigIn`
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
class ContactConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
address_street: str
address_neighborhood_city: str
address_zip: str
phone: str
email: str
business_hours: str
updated_at: datetime
class ContactConfigIn(BaseModel):
address_street: str
address_neighborhood_city: str
address_zip: str
phone: str
email: EmailStr
business_hours: str
@field_validator("address_street", "address_neighborhood_city", "address_zip",
"phone", "business_hours")
@classmethod
def not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Campo não pode ser vazio")
return v
```
- [ ] T004 Criar rota pública `GET /api/v1/contact-config` em `backend/app/routes/contact_config.py` e registrar o blueprint em `backend/app/__init__.py`
**`backend/app/routes/contact_config.py`**:
```python
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({"error": "Contact config not found"}), 404
return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json"))
```
**`backend/app/__init__.py`** — adicionar após o import de `homepage_bp`:
```python
from app.routes.contact_config import contact_config_bp
```
E registrar junto aos demais blueprints:
```python
app.register_blueprint(contact_config_bp)
```
**Checkpoint — Fase 1 concluída**: `GET /api/v1/contact-config` retorna os dados do banco. As fases 2 e 3 podem ser iniciadas em paralelo.
---
## Fase 2 — User Stories 1 + 3: Admin Edita Configuração e Endpoint Protegido (P1)
> **Objetivo**: Administrador acessa `/admin/contact-config`, vê o formulário preenchido
> com os valores atuais, edita e salva. O endpoint PUT rejeita acessos não autorizados.
>
> **Teste independente**: Autenticar como admin, acessar `/admin/contact-config`,
> alterar o telefone, clicar em "Salvar". Verificar `GET /api/v1/contact-config` retorna o
> novo valor. Verificar que `PUT /api/v1/admin/contact-config` sem token retorna HTTP 401.
### Implementação — User Stories 1 + 3
- [ ] T005 [US1] Adicionar rota `PUT /api/v1/admin/contact-config` em `backend/app/routes/admin.py` com `@require_admin`
**Adicionar imports** no topo de `backend/app/routes/admin.py`:
```python
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigIn, ContactConfigOut
```
**Adicionar a rota** (em qualquer ponto lógico do arquivo, ex.: próximo a outras rotas de configuração):
```python
@admin_bp.put("/contact-config")
@require_admin
def update_contact_config():
try:
data = ContactConfigIn.model_validate(request.get_json(force=True) or {})
except ValidationError as exc:
return jsonify({"errors": exc.errors()}), 422
config = ContactConfig.query.first()
if config is None:
config = ContactConfig(id=1, **data.model_dump())
db.session.add(config)
else:
for field, value in data.model_dump().items():
setattr(config, field, value)
db.session.commit()
db.session.refresh(config)
return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json"))
```
> `@require_admin` garante HTTP 401 para não-autenticados e HTTP 403 para não-admins (US3).
- [ ] T006 [P] [US1] Criar `frontend/src/services/contactConfig.ts` com `getContactConfig()` e `updateContactConfig()`
```typescript
import { api } from './api'
export interface ContactConfig {
address_street: string
address_neighborhood_city: string
address_zip: string
phone: string
email: string
business_hours: string
updated_at: string
}
export interface ContactConfigInput {
address_street: string
address_neighborhood_city: string
address_zip: string
phone: string
email: string
business_hours: string
}
export async function getContactConfig(): Promise<ContactConfig> {
const response = await api.get<ContactConfig>('/contact-config')
return response.data
}
export async function updateContactConfig(data: ContactConfigInput): Promise<ContactConfig> {
const response = await api.put<ContactConfig>('/admin/contact-config', data)
return response.data
}
```
- [ ] T007 [US1] Criar `frontend/src/pages/admin/AdminContactConfigPage.tsx` seguindo o padrão visual das demais páginas admin (ex.: `AdminAgentsPage.tsx`)
**Comportamento esperado**:
- `useEffect` faz `GET /api/v1/contact-config` ao montar e pré-preenche o form
- Estado local `form` com os 6 campos editáveis
- `handleSubmit` chama `updateContactConfig(form)`, exibe toast de sucesso ou erro
- Botão "Salvar" desabilitado enquanto `saving === true` (FR-019)
- Validação frontend: e-mail com formato válido, campos não vazios antes de submeter (FR-017)
- Erros de campo exibidos inline; toast global para erros de rede
**Estrutura do componente**:
```tsx
import { useState, useEffect } from 'react'
import { getContactConfig, updateContactConfig } from '../../services/contactConfig'
import type { ContactConfigInput } from '../../services/contactConfig'
const emptyForm: ContactConfigInput = {
address_street: '',
address_neighborhood_city: '',
address_zip: '',
phone: '',
email: '',
business_hours: '',
}
export default function AdminContactConfigPage() {
const [form, setForm] = useState<ContactConfigInput>(emptyForm)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getContactConfig()
.then(data => {
const { updated_at, ...editable } = data
setForm(editable)
})
.finally(() => setLoading(false))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
await updateContactConfig(form)
setSuccess(true)
} catch {
setError('Erro ao salvar. Tente novamente.')
} finally {
setSaving(false)
}
}
// Renderizar: loading skeleton → formulário com 5 inputs + 1 textarea + botão salvar
// Campos: Logradouro, Bairro/Cidade, CEP, Telefone, E-mail, Horário de Atendimento
}
```
- [ ] T008 [US1] Registrar rota `/admin/contact-config` em `frontend/src/App.tsx` e adicionar item `{ to: '/admin/contact-config', label: 'Conf. Contato' }` em `adminNavItems` em `frontend/src/components/Navbar.tsx`
**`frontend/src/App.tsx`** — localizar o trecho de rotas admin e adicionar:
```tsx
import AdminContactConfigPage from './pages/admin/AdminContactConfigPage'
// ...
<Route path="/admin/contact-config" element={<AdminContactConfigPage />} />
```
**`frontend/src/components/Navbar.tsx`** — acrescentar ao array `adminNavItems`:
```typescript
{ to: '/admin/contact-config', label: 'Conf. Contato' },
```
**Checkpoint — Fase 2 concluída**: Admin consegue editar e salvar a configuração de contato. Endpoint PUT retorna 401/403 para acessos não autorizados.
---
## Fase 3 — User Story 2: Página Pública de Contato Exibe Dados Dinâmicos (P1)
> **Objetivo**: A página `/contato` deixa de usar dados hardcoded e passa a consumir
> `GET /api/v1/contact-config`, preservando layout e estrutura visual existentes.
>
> **Teste independente**: Sem autenticação, acessar `/contato` e verificar que os dados
> exibidos correspondem ao banco (alterado via painel admin). A estrutura visual não muda.
### Implementação — User Story 2
- [ ] T009 [US2] Atualizar `frontend/src/pages/ContactPage.tsx` para consumir `getContactConfig()` no lugar dos dados hardcoded
**Comportamento esperado**:
- `useEffect` chama `getContactConfig()` ao montar
- Estado `config` inicializado como `null`; enquanto `loading === true` exibir skeleton ou spinner no lugar dos dados de contato (FR-021)
- Em caso de erro na requisição, exibir mensagem informativa em lugar dos dados — não renderizar valores obsoletos nem lançar erro de renderização (FR-022)
- Layout, classes CSS e demais seções da página NÃO devem ser alterados (FR-023)
**Campos a substituir** (remover literais hardcoded e usar `config.campo`):
- Endereço: `config.address_street`, `config.address_neighborhood_city`, `config.address_zip`
- Telefone: `config.phone`
- E-mail: `config.email`
- Horário de atendimento: `config.business_hours` (renderizar com `white-space: pre-line` ou equivalente para preservar quebras de linha)
**Exemplo de estrutura**:
```tsx
import { useState, useEffect } from 'react'
import { getContactConfig } from '../services/contactConfig'
import type { ContactConfig } from '../services/contactConfig'
export default function ContactPage() {
const [config, setConfig] = useState<ContactConfig | null>(null)
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState(false)
useEffect(() => {
getContactConfig()
.then(setConfig)
.catch(() => setFetchError(true))
.finally(() => setLoading(false))
}, [])
// ...restante do JSX existente — apenas substituir as strings hardcoded
// por {loading ? <Skeleton /> : fetchError ? <p>Informações indisponíveis</p> : config?.campo}
}
```
**Checkpoint — Fase 3 concluída**: `/contato` exibe dados dinâmicos da API. Todos os user stories são funcionais e testáveis de forma independente.
---
## Fase 4 — Polish & Verificações Finais
- [ ] T010 [P] Verificar que `backend/app/models/__init__.py` exporta `ContactConfig` (se o arquivo contiver imports explícitos dos modelos)
Se o arquivo importar modelos explicitamente, adicionar:
```python
from app.models.contact_config import ContactConfig # noqa: F401
```
- [ ] T011 [P] Aplicar a migration no banco de dados e verificar o registro singleton
```bash
# Dentro do container ou com .venv ativo:
flask --app run:app db upgrade
# Verificar:
# SELECT * FROM contact_config; → deve retornar 1 linha com id=1
```
---
## Dependências entre Tasks
```
T001 → T002 → T003 → T004 (blueprint público)
T005 (PUT admin)
T006 → T007 → T008 (rotas frontend)
T006 → T009 (ContactPage)
```
**Execução paralela possível**:
- T003 pode começar em paralelo com T002 (schemas não importam o modelo diretamente)
- T006, T007, T008, T009 podem ser desenvolvidos em paralelo após T001T004
---
## Escopo MVP
O **MVP mínimo** é completar as fases 1, 2 e 3 integralmente — as três user stories têm
prioridade P1 e são interdependentes para entregar valor. A fase 4 é verificação final.

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Página "Trabalhe Conosco"
**Purpose**: Validar completude e qualidade da especificação antes de avançar para o planejamento
**Created**: 2026-04-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Upload real de arquivo está explicitamente fora do escopo (Assumption documentada)
- Listagem no painel admin cobre apenas a API; UI React de `/admin/jobs` pode ser entregue em iteração futura
- Spec pronta para `/speckit.plan`

View file

@ -0,0 +1,210 @@
# API Contracts: Trabalhe Conosco
**Feature**: 028-trabalhe-conosco
**Phase**: 1 — Design & Contracts
**Base URL**: `/api/v1`
---
## Endpoints
| Método | Path | Auth | Descrição |
|--------|-------------------------|--------------|------------------------------------------|
| POST | `/jobs/apply` | Nenhuma | Submeter candidatura (público) |
| GET | `/admin/jobs` | `@require_admin` (JWT) | Listar candidaturas paginadas (admin) |
---
## POST /api/v1/jobs/apply
Endpoint público. Recebe os dados textuais da candidatura e persiste na tabela `job_applications`.
### Request
**Headers**
```
Content-Type: application/json
```
**Body** (JSON)
| Campo | Tipo | Obrigatório | Validações |
|-----------------|----------|-------------|-------------------------------------------------------------------------|
| `name` | string | Sim | Não pode ser vazio ou apenas espaços; strip aplicado |
| `email` | string | Sim | Formato de e-mail válido (RFC-5321 via `pydantic.EmailStr`) |
| `phone` | string | Não | Qualquer string; sem validação de formato nesta versão |
| `role_interest` | string | Sim | Deve ser exatamente um de: `"Corretor(a)"`, `"Assistente Administrativo"`, `"Estagiário(a)"`, `"Outro"` |
| `message` | string | Sim | Não pode ser vazio; máximo 5000 caracteres |
| `file_name` | string | Não | Nome do arquivo de currículo; sem conteúdo binário |
**Exemplo de request**
```json
{
"name": "Ana Lima",
"email": "ana.lima@email.com",
"phone": "(11) 98765-4321",
"role_interest": "Corretor(a)",
"message": "Tenho 5 anos de experiência no mercado imobiliário e gostaria de integrar a equipe.",
"file_name": "curriculo-ana-lima.pdf"
}
```
### Responses
#### 201 Created — Candidatura registrada com sucesso
```json
{
"message": "Candidatura recebida com sucesso"
}
```
#### 422 Unprocessable Entity — Dados inválidos
```json
{
"error": "Dados inválidos",
"details": [
{
"type": "value_error",
"loc": ["role_interest"],
"msg": "Value error, role_interest deve ser um de: Assistente Administrativo, Corretor(a), Estagiário(a), Outro",
"input": "Diretor",
"url": "https://errors.pydantic.dev/..."
}
]
}
```
#### 400 Bad Request — Body ausente ou não é JSON válido
```json
{
"error": "Dados inválidos",
"details": [...]
}
```
---
## GET /api/v1/admin/jobs
Endpoint protegido. Retorna listagem paginada de todas as candidaturas em ordem decrescente de `created_at`.
### Request
**Headers**
```
Authorization: Bearer <jwt_token>
Content-Type: application/json
```
**Query Parameters**
| Parâmetro | Tipo | Default | Restrições | Descrição |
|------------|---------|---------|-----------------|------------------------|
| `page` | integer | `1` | ≥ 1 | Número da página |
| `per_page` | integer | `20` | 1 100 | Registros por página |
**Exemplo de request**
```
GET /api/v1/admin/jobs?page=1&per_page=20
```
### Responses
#### 200 OK — Lista retornada com sucesso
```json
{
"items": [
{
"id": 7,
"name": "Ana Lima",
"email": "ana.lima@email.com",
"phone": "(11) 98765-4321",
"role_interest": "Corretor(a)",
"message": "Tenho 5 anos de experiência no mercado imobiliário...",
"file_name": "curriculo-ana-lima.pdf",
"status": "pending",
"created_at": "2026-04-21T14:35:00"
},
{
"id": 6,
"name": "Carlos Souza",
"email": "carlos@email.com",
"phone": null,
"role_interest": "Estagiário(a)",
"message": "Estudante de Administração em busca do primeiro emprego.",
"file_name": null,
"status": "pending",
"created_at": "2026-04-20T09:12:00"
}
],
"total": 42,
"page": 1,
"per_page": 20,
"pages": 3
}
```
**Schema do item** (`JobApplicationOut`)
| Campo | Tipo | Nullable | Descrição |
|-----------------|------------------|----------|----------------------------------|
| `id` | integer | Não | Identificador único |
| `name` | string | Não | Nome completo do candidato |
| `email` | string | Não | E-mail do candidato |
| `phone` | string \| null | Sim | Telefone (opcional) |
| `role_interest` | string | Não | Cargo de interesse selecionado |
| `message` | string | Não | Mensagem/apresentação |
| `file_name` | string \| null | Sim | Nome do arquivo de currículo |
| `status` | string | Não | Estado: `"pending"` (padrão) |
| `created_at` | string (ISO 8601)| Não | Data/hora do envio |
**Schema de paginação**
| Campo | Tipo | Descrição |
|------------|---------|-------------------------------------|
| `total` | integer | Total de candidaturas no sistema |
| `page` | integer | Página atual |
| `per_page` | integer | Registros retornados nesta página |
| `pages` | integer | Total de páginas |
#### 200 OK — Nenhuma candidatura registrada
```json
{
"items": [],
"total": 0,
"page": 1,
"per_page": 20,
"pages": 0
}
```
#### 401 Unauthorized — Token ausente ou inválido
```json
{
"error": "Token inválido ou ausente"
}
```
#### 403 Forbidden — Usuário autenticado sem permissão de admin
```json
{
"error": "Acesso negado"
}
```
---
## Notas de Implementação
- O endpoint `POST /api/v1/jobs/apply` **não** possui autenticação — qualquer cliente pode submeter.
- O endpoint `GET /api/v1/admin/jobs` usa o decorator `@require_admin` já existente no projeto, que valida o JWT e verifica a flag de administrador.
- O campo `created_at` é serializado pelo Pydantic como ISO 8601 sem timezone (`TIMESTAMP WITHOUT TIME ZONE` no PostgreSQL).
- `per_page` deve ser limitado a 100 no backend para evitar queries excessivamente grandes.
- Campos ausentes no body do POST são tratados pelo Pydantic: obrigatórios geram erro 422, opcionais recebem `None`.

View file

@ -0,0 +1,215 @@
# Data Model: Trabalhe Conosco
**Feature**: 028-trabalhe-conosco
**Phase**: 1 — Design & Contracts
**Source**: spec.md
---
## Entidade: JobApplication (Candidatura)
### Tabela: `job_applications`
| Coluna | Tipo SQL | Nullable | Default | Restrições |
|------------------|---------------------------------|----------|------------------|--------------------------------------------|
| `id` | `SERIAL` (INTEGER PK) | NOT NULL | auto-increment | PRIMARY KEY |
| `name` | `VARCHAR(150)` | NOT NULL | — | campo obrigatório |
| `email` | `VARCHAR(254)` | NOT NULL | — | formato e-mail válido (validado no backend)|
| `phone` | `VARCHAR(30)` | NULL | — | opcional conforme spec |
| `role_interest` | `VARCHAR(100)` | NOT NULL | — | enum: Corretor(a), Assistente Administrativo, Estagiário(a), Outro |
| `message` | `TEXT` | NOT NULL | — | apresentação/mensagem do candidato |
| `file_name` | `VARCHAR(255)` | NULL | — | nome do arquivo de currículo (sem upload) |
| `status` | `VARCHAR(50)` | NOT NULL | `'pending'` | estado da candidatura (pending / reviewed) |
| `created_at` | `TIMESTAMP WITHOUT TIME ZONE` | NOT NULL | `now()` (server) | imutável após criação |
### Índices
| Índice | Colunas | Motivo |
|------------------------------------|-----------------|------------------------------------------------|
| `ix_job_applications_created_at` | `created_at` | ordenação DESC na listagem admin |
| `ix_job_applications_status` | `status` | filtragem futura por estado |
### Invariantes
1. `name`, `email`, `role_interest` e `message` nunca são deixados em branco (validação Pydantic).
2. `email` deve ser validado com `pydantic.EmailStr` — formato RFC-5321.
3. `role_interest` deve ser um dos valores permitidos: `"Corretor(a)"`, `"Assistente Administrativo"`, `"Estagiário(a)"`, `"Outro"`.
4. `message` não pode ultrapassar 5000 caracteres (validação frontend + Pydantic `max_length`).
5. `phone` é opcional — sem validação de formato nesta versão.
6. `file_name` armazena apenas o nome do arquivo informado, sem conteúdo binário.
7. Múltiplas candidaturas do mesmo `email` são permitidas (sem deduplicação nesta versão).
8. Nenhum `DELETE` físico é exposto; o campo `status` permite rastreabilidade futura.
### Diagrama ER
```
job_applications
├── id PK SERIAL
├── name VARCHAR(150) NOT NULL
├── email VARCHAR(254) NOT NULL
├── phone VARCHAR(30) NULL
├── role_interest VARCHAR(100) NOT NULL
├── message TEXT NOT NULL
├── file_name VARCHAR(255) NULL
├── status VARCHAR(50) NOT NULL DEFAULT 'pending'
└── created_at TIMESTAMP NOT NULL DEFAULT now()
```
Sem relacionamentos com outras tabelas nesta versão. Entidade standalone.
---
## Modelo SQLAlchemy: `backend/app/models/job_application.py`
```python
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}>"
```
---
## Migration Alembic: `backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py`
```python
"""add job_applications table
Revision ID: i1j2k3l4m5n6
Revises: h1i2j3k4l5m6
Create Date: 2026-04-21 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "i1j2k3l4m5n6"
down_revision = "h1i2j3k4l5m6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"job_applications",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=150), nullable=False),
sa.Column("email", sa.String(length=254), nullable=False),
sa.Column("phone", sa.String(length=30), nullable=True),
sa.Column("role_interest", sa.String(length=100), nullable=False),
sa.Column("message", sa.Text(), nullable=False),
sa.Column("file_name", sa.String(length=255), nullable=True),
sa.Column(
"status",
sa.String(length=50),
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_job_applications_created_at", "job_applications", ["created_at"]
)
op.create_index(
"ix_job_applications_status", "job_applications", ["status"]
)
def downgrade():
op.drop_index("ix_job_applications_status", table_name="job_applications")
op.drop_index("ix_job_applications_created_at", table_name="job_applications")
op.drop_table("job_applications")
```
---
## Schemas Pydantic: `backend/app/schemas/job_application.py`
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
VALID_ROLES = {"Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"}
class JobApplicationIn(BaseModel):
name: str
email: EmailStr
phone: str | None = None
role_interest: str
message: str
file_name: str | None = None
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name não pode ser vazio")
return v
@field_validator("role_interest")
@classmethod
def role_must_be_valid(cls, v: str) -> str:
if v not in VALID_ROLES:
raise ValueError(f"role_interest deve ser um de: {', '.join(sorted(VALID_ROLES))}")
return v
@field_validator("message")
@classmethod
def message_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("message não pode ser vazia")
if len(v) > 5000:
raise ValueError("message não pode ultrapassar 5000 caracteres")
return v
class JobApplicationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
phone: str | None
role_interest: str
message: str
file_name: str | None
status: str
created_at: datetime
```

View file

@ -0,0 +1,354 @@
# Implementation Plan: Trabalhe Conosco
**Branch**: `028-trabalhe-conosco` | **Date**: 2026-04-21 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/028-trabalhe-conosco/spec.md`
## Summary
Criar a página pública `/trabalhe-conosco` com hero section, seção de benefícios (3 cards estáticos) e formulário de candidatura. O formulário submete via `POST /api/v1/jobs/apply` (endpoint público sem auth). As candidaturas são persistidas na tabela `job_applications` e recuperáveis pelo administrador via `GET /api/v1/admin/jobs` (paginado, protegido por `@require_admin`). Links adicionados no footer (coluna "A Imobiliária") e em `AgentsPage.tsx`. Dois novos blueprints Flask, novo model SQLAlchemy, migration Alembic, schemas Pydantic e uma nova página React com serviço Axios.
---
## Technical Context
**Language/Version**: Python 3.12 (backend) / TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT, Alembic (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
**Storage**: PostgreSQL 16 — nova tabela `job_applications`
**Testing**: pytest (backend)
**Target Platform**: Linux server (Docker container)
**Project Type**: web-service (Flask REST API) + SPA (React)
**Performance Goals**: página pública carrega em < 2s (SC-006); listagem admin paginada (20/página)
**Constraints**: sem upload real de arquivo (apenas `file_name` como texto); sem envio de e-mail; sem rate limiting nesta versão; múltiplas candidaturas do mesmo e-mail são permitidas
**Scale/Scope**: volume baixo de candidaturas; paginação padrão 20/página
---
## Constitution Check
| Princípio | Status | Observação |
|-----------|--------|------------|
| **I. Design-First** | ✅ PASS | Hero, cards de benefícios e formulário seguem design tokens dark do `DESIGN.md`; cores `#5e6ad2`, tipografia Inter, cards com `bg-panel border-borderSubtle` |
| **II. Separation of Concerns** | ✅ PASS | Flask retorna JSON puro; React SPA consome via Axios; zero lógica de renderização no backend |
| **III. Spec-Driven** | ✅ PASS | `spec.md` com user stories P1/P2/P3 e acceptance scenarios; plan derivado do spec |
| **IV. Data Integrity** | ✅ PASS | Migration Alembic (`i1j2k3l4m5n6`); Pydantic valida todos os inputs; `email: EmailStr`; sem raw SQL |
| **V. Security** | ✅ PASS | Endpoint admin protegido por `@require_admin` (JWT); endpoint público não expõe dados internos; sem exposição de stack traces em erro 500 |
| **VI. Simplicity First** | ✅ PASS | Sem upload binário (justificado na spec), sem e-mail transacional, sem rate limiting nesta versão; página de admin React adiada para iteração futura (conforme Assumptions da spec) |
**Veredicto**: Sem violações. Pode prosseguir com implementação.
---
## Project Structure
### Documentação (esta feature)
```text
specs/028-trabalhe-conosco/
├── spec.md # Especificação de produto
├── data-model.md # Entidade JobApplication, migration, schemas
├── plan.md # Este arquivo
├── contracts/
│ └── jobs-api.md # Contratos dos 2 endpoints REST
└── tasks.md # (Phase 2 — gerado por /speckit.tasks)
```
### Código-fonte (raiz do repositório)
```text
backend/
├── app/
│ ├── models/
│ │ └── job_application.py # NOVO — modelo SQLAlchemy JobApplication
│ ├── schemas/
│ │ └── job_application.py # NOVO — JobApplicationIn, JobApplicationOut
│ ├── routes/
│ │ └── jobs.py # NOVO — jobs_public_bp + jobs_admin_bp
│ └── __init__.py # MODIFICAR — importar model + registrar blueprints
└── migrations/
└── versions/
└── i1j2k3l4m5n6_add_job_applications.py # NOVO — cria tabela + índices
frontend/
└── src/
├── types/
│ └── jobApplication.ts # NOVO — interface JobApplication
├── services/
│ └── jobs.ts # NOVO — submitApplication(), listApplications()
├── pages/
│ └── JobsPage.tsx # NOVO — página pública /trabalhe-conosco
├── App.tsx # MODIFICAR — adicionar rota /trabalhe-conosco
└── components/
└── Footer.tsx # MODIFICAR — link "Trabalhe Conosco" em "A Imobiliária"
└── pages/
└── AgentsPage.tsx # MODIFICAR — link/botão "Trabalhe Conosco"
```
---
## Backend: Arquitetura Técnica
### Model: `backend/app/models/job_application.py`
Entidade standalone com 9 campos. Ver [data-model.md](data-model.md) para schema completo e invariantes.
Campos principais:
- `id` — PK SERIAL
- `name`, `email`, `role_interest`, `message` — obrigatórios
- `phone`, `file_name` — opcionais
- `status` — default `"pending"` (extensível futuramente)
- `created_at` — server_default `now()`, imutável
### Schemas Pydantic: `backend/app/schemas/job_application.py`
**`JobApplicationIn`** (entrada do endpoint público):
- Valida `name` (não vazio, strip), `email` (EmailStr), `role_interest` (enum de 4 opções), `message` (não vazio, max 5000 chars)
- `phone` e `file_name` opcionais
**`JobApplicationOut`** (saída do endpoint admin):
- Retorna todos os campos incluindo `id`, `status` e `created_at`
- `model_config = ConfigDict(from_attributes=True)` para serialização ORM
### Blueprints: `backend/app/routes/jobs.py`
Dois blueprints no mesmo arquivo, seguindo o padrão de `routes/agents.py`:
```python
jobs_public_bp = Blueprint("jobs_public", __name__, url_prefix="/api/v1")
jobs_admin_bp = Blueprint("jobs_admin", __name__, url_prefix="/api/v1/admin")
```
**`POST /api/v1/jobs/apply`** (público, sem autenticação):
1. `request.get_json(silent=True) or {}`
2. `JobApplicationIn.model_validate(data)` → 422 com `exc.errors()` se inválido
3. Instanciar `JobApplication(...)` e `db.session.add` + `db.session.commit()`
4. Retornar `{"message": "Candidatura recebida com sucesso"}`, HTTP 201
**`GET /api/v1/admin/jobs`** (protegido por `@require_admin`):
1. Query params: `page` (default 1), `per_page` (default 20, max 100)
2. `JobApplication.query.order_by(JobApplication.created_at.desc()).paginate(page, per_page, error_out=False)`
3. Serializar com `JobApplicationOut` e retornar envelope paginado:
```json
{
"items": [...],
"total": 42,
"page": 1,
"per_page": 20,
"pages": 3
}
```
4. `@require_admin` dispara 401/403 automaticamente
### Registro em `backend/app/__init__.py`
Dois patches necessários:
```python
# Importar model (para Flask-Migrate detectar)
from app.models import job_application as _job_application_models # noqa: F401
# Importar e registrar blueprints
from app.routes.jobs import jobs_public_bp, jobs_admin_bp
app.register_blueprint(jobs_public_bp)
app.register_blueprint(jobs_admin_bp)
```
### Migration Alembic
Arquivo: `i1j2k3l4m5n6_add_job_applications.py`
- `down_revision = "h1i2j3k4l5m6"` (migration atual mais recente: `create_contact_config`)
- Cria tabela `job_applications` com 9 colunas
- Cria índices: `ix_job_applications_created_at`, `ix_job_applications_status`
- `downgrade()` desfaz índices e tabela
Ver código completo em [data-model.md](data-model.md).
---
## Frontend: Arquitetura Técnica
### Types: `frontend/src/types/jobApplication.ts`
```typescript
export interface JobApplicationPayload {
name: string
email: string
phone?: string
role_interest: string
message: string
file_name?: string
}
export interface JobApplication {
id: number
name: string
email: string
phone: string | null
role_interest: string
message: string
file_name: string | null
status: string
created_at: string
}
export interface JobApplicationsResponse {
items: JobApplication[]
total: number
page: number
per_page: number
pages: number
}
```
### Service: `frontend/src/services/jobs.ts`
```typescript
import api from './api'
import type { JobApplicationPayload, JobApplicationsResponse } from '../types/jobApplication'
export async function submitApplication(payload: JobApplicationPayload): Promise<void> {
await api.post('/api/v1/jobs/apply', payload)
}
export async function listApplications(
page = 1,
perPage = 20
): Promise<JobApplicationsResponse> {
const { data } = await api.get('/api/v1/admin/jobs', {
params: { page, per_page: perPage },
})
return data
}
```
### Página: `frontend/src/pages/JobsPage.tsx`
Estrutura da página (3 seções, de cima para baixo):
#### 1. Hero Section
```
bg-canvas | max-w-[1200px] mx-auto px-6 pt-16 pb-10
├── eyebrow: "Faça parte do nosso time" (text-[#5e6ad2] uppercase tracking-widest)
├── h1: "Trabalhe Conosco" (text-3xl md:text-4xl font-semibold text-textPrimary)
└── subtítulo: texto descritivo (text-textSecondary)
```
#### 2. Seção "Por que trabalhar conosco?" (3 cards estáticos)
```
max-w-[1200px] mx-auto px-6 py-10
├── h2: "Por que trabalhar conosco?" (text-xl font-semibold text-textPrimary mb-6)
└── grid grid-cols-1 md:grid-cols-3 gap-5
├── Card 1: ícone + "Ambiente Colaborativo" + descrição
├── Card 2: ícone + "Crescimento Profissional" + descrição
└── Card 3: ícone + "Remuneração Competitiva" + descrição
(cada card: bg-panel border border-borderSubtle rounded-2xl p-6)
```
#### 3. Formulário de Candidatura
```
max-w-[640px] mx-auto px-6 pb-20
├── h2: "Envie sua candidatura"
└── <form onSubmit={handleSubmit}>
├── name — input text, obrigatório
├── email — input email, obrigatório, validação RFC
├── phone — input tel, opcional
├── role_interest — select (4 opções), obrigatório
├── message — textarea, obrigatório, max 5000 chars, contador de chars
├── file (currículo) — input file, accept=".pdf", max 2MB (validação frontend only)
│ ao selecionar: setFileName(file.name), não envia binário
└── submit button "Enviar Candidatura"
```
**Gerenciamento de estado** (hooks locais, sem Redux/Context):
- `formData` — estado do formulário
- `fileName` — nome do arquivo selecionado (string | null)
- `errors` — Record<string, string> para mensagens por campo
- `submitting` — boolean, desabilita botão durante requisição
- `submitted` — boolean, exibe mensagem de sucesso e reseta form
- `serverError` — string | null, erro de rede/500
**Validação frontend** (antes de chamar `submitApplication`):
- `name`: obrigatório, trim
- `email`: obrigatório, regex RFC simples
- `role_interest`: obrigatório, não pode ser valor vazio/placeholder
- `message`: obrigatório, max 5000 chars
- `file`: se presente, extensão `.pdf` e tamanho ≤ 2 MB; apenas registra `file_name`
**Fluxo de submit**:
1. Validar campos → exibir erros por campo se inválido
2. `setSubmitting(true)`
3. `submitApplication({ name, email, phone, role_interest, message, file_name: fileName ?? undefined })`
4. Sucesso → `setSubmitted(true)`, resetar `formData`, `setFileName(null)`
5. Erro 422 → parsear `details` e mapear para `errors` por campo
6. Erro ≥ 500 ou rede → `setServerError("Erro ao enviar candidatura. Tente novamente.")`
7. `finally``setSubmitting(false)`
**Design tokens** (seguir padrão do projeto):
- Inputs: `w-full bg-panel border border-borderSubtle rounded-lg px-4 py-2.5 text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/50`
- Labels: `text-sm font-medium text-textSecondary mb-1.5`
- Erros: `text-xs text-red-400 mt-1`
- Botão submit: `w-full bg-[#5e6ad2] hover:bg-[#4f5bbf] text-white font-medium py-2.5 rounded-lg transition-colors duration-150 disabled:opacity-60`
- Mensagem de sucesso: `bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-6 text-center`
### Modificação: `frontend/src/App.tsx`
Adicionar a rota da nova página:
```tsx
import JobsPage from './pages/JobsPage'
// ...
<Route path="/trabalhe-conosco" element={<JobsPage />} />
```
### Modificação: `frontend/src/components/Footer.tsx`
Adicionar link na coluna "A Imobiliária" (após "Política de Privacidade"):
```tsx
<FooterLink to="/trabalhe-conosco">Trabalhe Conosco</FooterLink>
```
### Modificação: `frontend/src/pages/AgentsPage.tsx`
Adicionar banner/botão após o grid de corretores e antes do `<Footer />`:
```tsx
{/* CTA Trabalhe Conosco */}
<div className="max-w-[1200px] mx-auto px-6 pb-20">
<div className="bg-panel border border-borderSubtle rounded-2xl p-8 flex flex-col sm:flex-row items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-textPrimary">Quer fazer parte do time?</h2>
<p className="text-textSecondary text-sm mt-1">Envie sua candidatura e venha crescer conosco.</p>
</div>
<Link
to="/trabalhe-conosco"
className="shrink-0 bg-[#5e6ad2] hover:bg-[#4f5bbf] text-white font-medium px-5 py-2.5 rounded-lg transition-colors duration-150 text-sm"
>
Trabalhe Conosco
</Link>
</div>
</div>
```
---
## Sequência de Implementação
1. **Migration** — criar `i1j2k3l4m5n6_add_job_applications.py` e rodar `flask db upgrade`
2. **Model** — criar `backend/app/models/job_application.py`
3. **Schemas** — criar `backend/app/schemas/job_application.py`
4. **Routes** — criar `backend/app/routes/jobs.py`
5. **Register** — modificar `backend/app/__init__.py` (model import + blueprints)
6. **Types** — criar `frontend/src/types/jobApplication.ts`
7. **Service** — criar `frontend/src/services/jobs.ts`
8. **Page** — criar `frontend/src/pages/JobsPage.tsx`
9. **Route** — modificar `frontend/src/App.tsx`
10. **Footer** — modificar `frontend/src/components/Footer.tsx`
11. **AgentsPage** — modificar `frontend/src/pages/AgentsPage.tsx`
---
## Complexity Tracking
Sem violações de constituição — seção não aplicável.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|--------------------------------------|
| — | — | — |

View file

@ -0,0 +1,150 @@
# Feature Specification: Página "Trabalhe Conosco"
**Feature Branch**: `028-trabalhe-conosco`
**Created**: 2026-04-21
**Status**: Draft
---
## Contexto
O site imobiliário atualmente não oferece um canal formal para que candidatos manifestem interesse em trabalhar na empresa. Esse contato ocorre de maneira informal — por telefone, e-mail avulso ou presencialmente — sem rastreabilidade e sem uma experiência consistente para o candidato.
Esta spec cobre a criação de uma página pública "/trabalhe-conosco" com formulário de candidatura, armazenamento das submissões em banco de dados e listagem das candidaturas no painel administrativo. A página também deve ser acessível via links no footer e na página de equipe, tornando o recrutamento um ponto de contato organizado e profissional.
---
## User Scenarios & Testing
### User Story 1 — Candidato Envia Formulário de Candidatura (Priority: P1)
Um candidato interessado em trabalhar na imobiliária acessa a página "/trabalhe-conosco", preenche o formulário com seus dados e envia sua candidatura.
**Why this priority**: É o núcleo da feature. Toda a proposta de valor gira em torno dessa ação — sem ela, a página é apenas um conteúdo estático sem utilidade.
**Independent Test**: Acessar `/trabalhe-conosco` sem autenticação, preencher todos os campos obrigatórios (nome, e-mail, telefone, cargo de interesse, mensagem) e submeter. Verificar que uma mensagem de sucesso é exibida e que a candidatura aparece na listagem do painel admin em `GET /api/v1/admin/jobs`.
**Acceptance Scenarios**:
1. **Given** um visitante não autenticado na página `/trabalhe-conosco`, **When** ele preenche todos os campos obrigatórios e clica em "Enviar Candidatura", **Then** a candidatura é registrada no sistema e uma mensagem de confirmação é exibida ao candidato.
2. **Given** o formulário preenchido corretamente, **When** o campo de cargo de interesse é "Corretor(a)", **Then** o valor enviado e armazenado reflete exatamente a opção selecionada.
3. **Given** o formulário preenchido corretamente com um arquivo PDF informado, **When** o candidato submete, **Then** o nome do arquivo é registrado junto com a candidatura, mesmo que o conteúdo do arquivo não seja armazenado nesta versão.
4. **Given** o formulário submetido com sucesso, **When** o candidato tenta submeter novamente sem recarregar a página, **Then** o formulário é limpo/resetado após o sucesso, prevenindo envios duplicados acidentais.
5. **Given** falha de rede durante o envio, **When** a requisição não é completada, **Then** uma mensagem de erro informativa é exibida e o candidato pode tentar novamente sem perder os dados preenchidos.
---
### User Story 2 — Visitante Descobre a Oportunidade Pelo Site (Priority: P1)
Um visitante que navega pelo footer ou pela página de equipe (/corretores) encontra o link "Trabalhe Conosco" e acessa a página de candidatura.
**Why this priority**: Sem pontos de entrada adequados, a página não é encontrada organicamente dentro do site, tornando o canal de recrutamento inacessível na prática.
**Independent Test**: Acessar o footer do site e verificar a presença do link "Trabalhe Conosco" na coluna "A Imobiliária". Acessar `/corretores` e verificar a presença do link/botão "Trabalhe Conosco". Clicar em cada link e confirmar que navega para `/trabalhe-conosco`.
**Acceptance Scenarios**:
1. **Given** um visitante em qualquer página do site, **When** ele visualiza o footer, **Then** o link "Trabalhe Conosco" está visível na coluna "A Imobiliária".
2. **Given** um visitante na página `/corretores`, **When** ele visualiza a página de equipe, **Then** existe um elemento (link ou botão) com o texto "Trabalhe Conosco" que leva a `/trabalhe-conosco`.
3. **Given** um visitante clicando no link "Trabalhe Conosco" a partir do footer, **When** a navegação ocorre, **Then** ele é direcionado para `/trabalhe-conosco` com a página completa carregada.
4. **Given** um visitante em dispositivo móvel, **When** ele visualiza o footer ou a página `/corretores`, **Then** o link "Trabalhe Conosco" é igualmente acessível e funcional.
---
### User Story 3 — Administrador Visualiza as Candidaturas Recebidas (Priority: P2)
O administrador da imobiliária acessa o painel admin e visualiza uma listagem paginada de todas as candidaturas enviadas pelos candidatos.
**Why this priority**: Sem visibilidade das candidaturas, o canal de recrutamento não entrega valor operacional. A listagem é o produto final que o administrador consume para iniciar o processo seletivo.
**Independent Test**: Com candidaturas já enviadas via formulário público, autenticar como administrador e consultar `GET /api/v1/admin/jobs`. Verificar que a resposta inclui os dados dos candidatos (nome, e-mail, cargo, data de envio) com paginação funcional.
**Acceptance Scenarios**:
1. **Given** um administrador autenticado, **When** ele acessa o endpoint de listagem de candidaturas, **Then** a resposta inclui uma lista paginada com nome, e-mail, telefone, cargo de interesse, data de envio e nome do arquivo informado para cada candidatura.
2. **Given** mais de 20 candidaturas no sistema, **When** o administrador consulta a segunda página, **Then** os resultados são diferentes da primeira página e o total de candidaturas é informado na resposta.
3. **Given** um usuário não autenticado tentando acessar o endpoint de listagem, **When** a requisição é enviada, **Then** o sistema retorna erro de acesso não autorizado (HTTP 401).
4. **Given** um token de usuário comum (não administrador), **When** ele tenta acessar o endpoint de listagem, **Then** o sistema retorna erro de permissão insuficiente (HTTP 403).
5. **Given** nenhuma candidatura registrada, **When** o administrador consulta a listagem, **Then** o sistema retorna uma lista vazia com o total zerado, sem erro.
---
### User Story 4 — Candidato Vê a Página com Conteúdo Institucional (Priority: P3)
Um candidato acessa `/trabalhe-conosco` e, além do formulário, encontra uma apresentação institucional da imobiliária como empregadora, com destaque para benefícios de trabalhar na empresa.
**Why this priority**: Enriquece a experiência do candidato e posiciona a imobiliária como empregadora, mas não bloqueia o funcionamento do recrutamento em si.
**Independent Test**: Acessar `/trabalhe-conosco` e verificar que a página contém: uma hero section com título e subtítulo, uma seção "Por que trabalhar conosco?" com exatamente 3 cards de benefícios, e o formulário de candidatura.
**Acceptance Scenarios**:
1. **Given** qualquer visitante acessando `/trabalhe-conosco`, **When** a página carrega, **Then** uma hero section com título principal e subtítulo descritivo é exibida no topo.
2. **Given** a página carregada, **When** o visitante rola a tela, **Then** uma seção "Por que trabalhar conosco?" com 3 cards de benefícios é visível antes do formulário.
3. **Given** a página carregada, **When** o visitante acessa em dispositivo móvel, **Then** hero section, cards de benefícios e formulário se adaptam ao layout vertical sem perda de conteúdo ou sobreposição visual.
---
### Edge Cases
- O que acontece se o candidato enviar o formulário com um e-mail em formato inválido? A validação deve ocorrer no frontend antes do envio, e o backend deve rejeitar com HTTP 422 e mensagem descritiva.
- O que acontece se o campo de mensagem ultrapassar o limite de caracteres? O sistema deve validar e informar o candidato antes de enviar.
- O que acontece se o candidato tentar enviar o mesmo e-mail múltiplas vezes? Por padrão, múltiplas candidaturas do mesmo e-mail são permitidas (sem deduplicação nesta versão).
- O que acontece se o candidato selecionar um arquivo que não seja PDF ou que exceda 2 MB? O frontend deve bloquear o envio e exibir mensagem de erro clara. Nesta versão, apenas o nome do arquivo é registrado — não há upload real de arquivo.
- O que acontece se o backend retornar erro 500 durante o envio? O frontend deve exibir mensagem genérica de erro sem expor detalhes técnicos.
- Como o endpoint público `POST /api/v1/jobs/apply` se comporta em caso de sobrecarga? Por padrão, o endpoint não possui rate limiting nesta versão — isso pode ser adicionado futuramente.
---
## Requirements
### Functional Requirements
- **FR-001**: O sistema DEVE disponibilizar a rota pública `/trabalhe-conosco` no frontend, acessível sem autenticação.
- **FR-002**: A página DEVE conter uma hero section com título e subtítulo configurados estaticamente.
- **FR-003**: A página DEVE conter uma seção "Por que trabalhar conosco?" com 3 cards de benefícios (conteúdo estático).
- **FR-004**: A página DEVE conter um formulário de candidatura com os campos: nome completo, e-mail, telefone, cargo de interesse (select), mensagem/apresentação e seleção de arquivo de currículo.
- **FR-005**: O campo de cargo de interesse DEVE oferecer as opções: Corretor(a), Assistente Administrativo, Estagiário(a), Outro.
- **FR-006**: O formulário DEVE validar campos obrigatórios (nome, e-mail, cargo, mensagem) antes do envio, exibindo mensagens de erro por campo.
- **FR-007**: O campo de e-mail DEVE validar formato de e-mail válido no frontend antes do envio.
- **FR-008**: O campo de arquivo DEVE aceitar apenas arquivos PDF e rejeitar arquivos acima de 2 MB, com mensagem de erro clara — a validação ocorre no frontend; nesta versão, apenas o nome do arquivo é enviado ao backend.
- **FR-009**: Após envio bem-sucedido, o formulário DEVE exibir uma mensagem de confirmação ao candidato e limpar os campos.
- **FR-010**: O sistema DEVE disponibilizar o endpoint público `POST /api/v1/jobs/apply` que receba e persista os dados textuais da candidatura (sem autenticação).
- **FR-011**: O backend DEVE validar os dados recebidos no endpoint de candidatura e retornar HTTP 422 com detalhes para dados inválidos.
- **FR-012**: O sistema DEVE armazenar as candidaturas em uma tabela `job_applications` com os campos: nome completo, e-mail, telefone, cargo de interesse, mensagem, nome do arquivo informado e data/hora do envio.
- **FR-013**: O sistema DEVE disponibilizar o endpoint protegido `GET /api/v1/admin/jobs` que retorne uma listagem paginada das candidaturas, acessível apenas por administradores autenticados.
- **FR-014**: O endpoint de listagem DEVE retornar para cada candidatura: nome, e-mail, telefone, cargo de interesse, mensagem, nome do arquivo e data de envio.
- **FR-015**: O endpoint de listagem DEVE suportar paginação com parâmetros `page` e `per_page`, retornando o total de registros.
- **FR-016**: O footer do site DEVE conter o link "Trabalhe Conosco" na coluna "A Imobiliária", navegando para `/trabalhe-conosco`.
- **FR-017**: A página `/corretores` DEVE conter um link ou botão "Trabalhe Conosco" navegando para `/trabalhe-conosco`.
- **FR-018**: O design da página DEVE seguir os design tokens existentes do projeto (cores, tipografia Inter, estilo de cards limpos).
### Key Entities
- **JobApplication**: Registro de candidatura enviada por um candidato. Atributos principais: identificador único, nome completo do candidato, e-mail, telefone, cargo de interesse selecionado, texto de apresentação/mensagem, nome do arquivo de currículo informado (opcional), data e hora do envio. Sem relacionamento com outras entidades nesta versão.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Um candidato consegue localizar e acessar a página "Trabalhe Conosco" a partir do footer ou da página de equipe em no máximo 2 cliques.
- **SC-002**: Um candidato consegue preencher e enviar o formulário de candidatura completo em menos de 3 minutos.
- **SC-003**: 100% das candidaturas enviadas com dados válidos são armazenadas e recuperáveis pelo administrador via painel admin.
- **SC-004**: Tentativas de acesso não autorizado ao endpoint de listagem de candidaturas são bloqueadas em 100% dos casos.
- **SC-005**: O formulário exibe mensagem de erro específica para cada campo inválido sem necessidade de recarregar a página.
- **SC-006**: A página carrega e exibe todo o conteúdo estático (hero, benefícios, formulário) em menos de 2 segundos em conexões de banda larga padrão.
---
## Assumptions
- O upload real do arquivo de currículo (armazenamento binário no servidor ou serviço de storage) está fora do escopo desta versão; apenas o nome do arquivo informado pelo candidato é salvo como texto.
- Não há deduplicação de candidaturas por e-mail nesta versão — múltiplas submissões do mesmo endereço são permitidas.
- Os 3 benefícios exibidos na seção "Por que trabalhar conosco?" são conteúdo estático definido em tempo de desenvolvimento; não há interface de gerenciamento para esse conteúdo.
- Não há envio de e-mail de confirmação ao candidato nem notificação por e-mail ao administrador nesta versão.
- O endpoint público de candidatura não possui rate limiting nesta versão.
- A listagem de candidaturas no painel admin é acessível via API; a interface visual no painel admin (página React de `/admin/jobs`) pode ser entregue em iteração futura, não sendo requisito desta spec.
- O padrão de autenticação de administrador já implementado no projeto (`require_admin`) é suficiente e será reutilizado para proteger o endpoint de listagem.
- O campo de telefone é opcional para o candidato, mas recomendado — a validação de formato não é obrigatória nesta versão.

View file

@ -0,0 +1,217 @@
---
description: "Task list para a feature 028 - Trabalhe Conosco"
---
# Tasks: Trabalhe Conosco (028)
**Input**: Design documents de `specs/028-trabalhe-conosco/`
**Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/jobs-api.md ✅
## Format: `[ID] [P?] [Story?] Description — arquivo`
- **[P]**: Pode executar em paralelo (arquivo diferente, sem bloqueadores incompletos)
- **[Story]**: User story correspondente (US1, US2, US3, US4)
- Arquivo exato indicado em cada task
---
## Phase 1: Foundational — Backend (Bloqueador de tudo)
**Purpose**: Migration, model, schemas e rotas Flask precisam existir antes que qualquer integração frontend possa ser testada contra o servidor real.
**⚠️ CRÍTICO**: Nenhuma fase de user story pode começar até esta fase estar completa.
- [ ] T001 Criar migration Alembic `i1j2k3l4m5n6` em `backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py` com `down_revision = "h1i2j3k4l5m6"` — implementar `upgrade()` criando a tabela `job_applications` (9 colunas conforme data-model.md: id SERIAL PK, name VARCHAR(150) NOT NULL, email VARCHAR(254) NOT NULL, phone VARCHAR(30) NULL, role_interest VARCHAR(100) NOT NULL, message TEXT NOT NULL, file_name VARCHAR(255) NULL, status VARCHAR(50) NOT NULL server_default `'pending'`, created_at TIMESTAMP NOT NULL server_default `now()`) + 2 índices (`ix_job_applications_created_at` em `created_at`, `ix_job_applications_status` em `status`); `downgrade()` remove índices e tabela na ordem inversa
- [ ] T002 Criar modelo SQLAlchemy `JobApplication` em `backend/app/models/job_application.py` — classe com `__tablename__ = "job_applications"`, 9 colunas mapeando o schema da tabela (status com `default="pending"`, created_at com `server_default=db.func.now()`), constante `ROLE_INTEREST_OPTIONS = ["Corretor(a)", "Assistente Administrativo", "Estagiário(a)", "Outro"]` e `__repr__` com id + email
- [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/job.py` — definir 3 classes:
- `JobApplicationIn(BaseModel)`: campos `name: str` (strip, não vazio), `email: EmailStr`, `phone: str | None = None`, `role_interest: str` (validado contra `ROLE_INTEREST_OPTIONS` via `@field_validator`), `message: str` (max_length=5000, não vazio), `file_name: str | None = None`
- `JobApplicationOut(BaseModel)`: todos os campos de `JobApplicationIn` + `id: int`, `status: str`, `created_at: datetime`; `model_config = ConfigDict(from_attributes=True)`
- `PaginatedJobApplications(BaseModel)`: `items: list[JobApplicationOut]`, `total: int`, `page: int`, `per_page: int`, `pages: int`
- [ ] T004 Criar rotas em `backend/app/routes/jobs.py` com dois blueprints:
- `jobs_public_bp = Blueprint("jobs_public", __name__)`: endpoint `POST /jobs/apply` público — valida body via `JobApplicationIn` (retorna 422 com `{"error": "Dados inválidos", "details": ...}` em ValidationError), cria e salva `JobApplication` via `db.session`, retorna `{"message": "Candidatura recebida com sucesso"}` com status 201
- `jobs_admin_bp = Blueprint("jobs_admin", __name__)`: endpoint `GET /jobs` decorado com `@require_admin` — lê query params `page` (default 1, ≥ 1) e `per_page` (default 20, clamp 1100), consulta `JobApplication.query.order_by(JobApplication.created_at.desc()).paginate(...)`, serializa via `PaginatedJobApplications` e retorna JSON 200
- [ ] T005 Registrar model e blueprints em `backend/app/__init__.py`:
- Na seção de imports de models, adicionar `from app.models import job_application as _job_application_models`
- Registrar `jobs_public_bp` com `url_prefix="/api/v1"` e `jobs_admin_bp` com `url_prefix="/api/v1/admin"` na função `create_app()`
- [ ] T006 Aplicar migration no container e verificar schema: `docker-compose exec backend flask db upgrade` → confirmar tabela com `docker-compose exec db psql -U postgres -d saas_imobiliaria -c "\d job_applications"`
**Checkpoint**: `curl -X POST http://localhost:5000/api/v1/jobs/apply` com body válido retorna 201. `GET /api/v1/admin/jobs` sem token retorna 401.
---
## Phase 2: User Story 1 — Candidato Envia Formulário (Priority: P1) 🎯 MVP
**Goal**: Página `/trabalhe-conosco` com formulário funcional que submete via `POST /api/v1/jobs/apply`, exibe confirmação de sucesso e mantém dados em caso de erro de rede.
**Independent Test**: Acessar `/trabalhe-conosco` sem autenticação, preencher todos os campos obrigatórios (nome, e-mail, cargo de interesse, mensagem) e clicar em "Enviar Candidatura". Verificar que uma mensagem de confirmação é exibida e que `GET /api/v1/admin/jobs` (com token admin) lista a candidatura recebida.
- [ ] T007 [P] [US1] Criar interface TypeScript em `frontend/src/types/jobApplication.ts`:
```typescript
export interface JobApplicationPayload {
name: string;
email: string;
phone?: string;
role_interest: string;
message: string;
file_name?: string;
}
export const ROLE_INTEREST_OPTIONS = [
"Corretor(a)",
"Assistente Administrativo",
"Estagiário(a)",
"Outro",
] as const;
```
- [ ] T008 [P] [US1] Criar `frontend/src/services/jobsService.ts` com função `submitApplication(data: JobApplicationPayload): Promise<void>` — chama `api.post("/api/v1/jobs/apply", data)` via instância Axios do projeto e relança o erro para tratamento no componente
- [ ] T009 [US1] Criar `frontend/src/pages/JobsPage.tsx` com formulário de candidatura:
- Campos controlados com `useState`: `name`, `email`, `phone`, `role_interest` (select com `ROLE_INTEREST_OPTIONS`), `message` (textarea, contador de caracteres até 5000), `file_name` (input file decorativo — apenas registra `e.target.files?.[0]?.name`)
- Validação frontend antes do submit: e-mail formato válido, campos obrigatórios não vazios, message ≤ 5000 chars, arquivo (se presente) deve ser PDF e ≤ 2 MB
- Estado `submitting: boolean` para desabilitar o botão durante o envio
- Submit: chama `submitApplication()`, em sucesso exibe mensagem de confirmação e limpa o formulário; em erro de rede exibe mensagem genérica sem apagar os dados preenchidos
- Estilo: dark theme do projeto (`bg-panel`, `border-borderSubtle`, accent `#5e6ad2`, tipografia Inter, Tailwind CSS)
- [ ] T010 [US1] Adicionar rota `/trabalhe-conosco` em `frontend/src/App.tsx`: importar `JobsPage` e inserir `<Route path="/trabalhe-conosco" element={<JobsPage />} />` entre as rotas públicas
**Checkpoint**: Formulário em `/trabalhe-conosco` envia candidatura, recebe 201 e exibe confirmação. Erro de rede exibe mensagem sem apagar campos.
---
## Phase 3: User Story 2 — Visitante Descobre a Oportunidade (Priority: P1)
**Goal**: Links "Trabalhe Conosco" no footer (coluna "A Imobiliária") e na página `/corretores` tornam a página de candidatura descobrível organicamente.
**Independent Test**: Acessar qualquer página e verificar que o footer contém o link "Trabalhe Conosco" na coluna "A Imobiliária". Acessar `/corretores` e verificar que existe elemento com texto "Trabalhe Conosco" que navega para `/trabalhe-conosco`.
- [ ] T011 [P] [US2] Adicionar link "Trabalhe Conosco" em `frontend/src/components/Footer.tsx` — localizar a coluna "A Imobiliária" e inserir `<Link to="/trabalhe-conosco">Trabalhe Conosco</Link>` seguindo o mesmo padrão visual dos demais links da coluna
- [ ] T012 [P] [US2] Adicionar link/botão "Trabalhe Conosco" em `frontend/src/pages/AgentsPage.tsx` — inserir elemento (link `<Link>` ou botão secundário) com texto "Trabalhe Conosco" e `href`/`to="/trabalhe-conosco"` em posição visível na página (ex.: ao final da seção de equipe ou como chamada à ação após o grid de corretores)
**Checkpoint**: Footer exibe link em todas as páginas. `/corretores` exibe elemento que navega para `/trabalhe-conosco`.
---
## Phase 4: User Story 3 — Administrador Visualiza Candidaturas (Priority: P2)
**Goal**: Endpoint `GET /api/v1/admin/jobs` (implementado na Phase 1) retorna listagem paginada e corretamente serializada. Serviço Axios disponível para consumo futuro no painel admin.
**Independent Test**: Autenticar como admin e consultar `GET /api/v1/admin/jobs?page=1&per_page=20`. Verificar: resposta 200 com campos `items`, `total`, `page`, `per_page`, `pages`; cada item contém id, name, email, phone, role_interest, message, file_name, status, created_at. Sem token: 401. Token não-admin: 403.
- [ ] T013 [US3] Adicionar função `listApplications(page?: number, perPage?: number): Promise<PaginatedJobApplications>` em `frontend/src/services/jobsService.ts` — chama `api.get("/api/v1/admin/jobs", { params: { page, per_page: perPage } })` com header Authorization via instância autenticada do Axios; adicionar tipo `PaginatedJobApplications` em `frontend/src/types/jobApplication.ts` espelhando o schema do contrato (`items: JobApplicationItem[]`, `total`, `page`, `per_page`, `pages`)
**Checkpoint**: `listApplications()` pode ser chamado do console do browser (após login admin) e retorna dados paginados com a estrutura correta.
---
## Phase 5: User Story 4 — Conteúdo Institucional (Priority: P3)
**Goal**: Página `/trabalhe-conosco` enriquecida com hero section e seção "Por que trabalhar conosco?" com 3 cards de benefícios, posicionados acima do formulário.
**Independent Test**: Acessar `/trabalhe-conosco` e verificar: hero section com título principal e subtítulo no topo; seção "Por que trabalhar conosco?" com exatamente 3 cards de benefícios antes do formulário; layout responsivo sem sobreposição em mobile.
- [ ] T014 [US4] Adicionar hero section no topo de `frontend/src/pages/JobsPage.tsx` — bloco com título principal (ex.: "Faça parte da nossa equipe") e subtítulo descritivo; seguir design tokens dark (`text-primary`, `text-secondary`, fundo com gradiente sutil ou `bg-surface`); posicionar acima dos cards de benefícios e do formulário
- [ ] T015 [US4] Adicionar seção "Por que trabalhar conosco?" em `frontend/src/pages/JobsPage.tsx` com 3 cards de benefícios estáticos — cada card tem ícone SVG, título e descrição; layout em grid responsivo (`grid-cols-1 md:grid-cols-3`); estilo `bg-panel border border-borderSubtle rounded-xl`; posicionar entre o hero e o formulário de candidatura. Sugestão de conteúdo dos cards: "Crescimento Profissional" / "Ambiente Colaborativo" / "Comissões Competitivas"
**Checkpoint**: `/trabalhe-conosco` exibe hero → 3 benefit cards → formulário nessa ordem. Em mobile (375 px) os cards empilham verticalmente sem overflow horizontal.
---
## Phase 6: Polish & Verificação Final
- [ ] T016 Executar verificação end-to-end manualmente:
1. `GET /api/v1/admin/jobs` sem token → 401
2. `POST /api/v1/jobs/apply` com body válido → 201, candidatura registrada
3. `POST /api/v1/jobs/apply` com e-mail inválido → 422 com `details`
4. `GET /api/v1/admin/jobs?page=1` com token admin → 200 com a candidatura enviada
5. Browser: `/trabalhe-conosco` renderiza hero + 3 cards + formulário
6. Browser: footer → link "Trabalhe Conosco" → navega para `/trabalhe-conosco`
7. Browser: `/corretores` → link "Trabalhe Conosco" → navega para `/trabalhe-conosco`
---
## Dependencies & Execution Order
### Dependências entre fases
```
Phase 1 (Foundational Backend)
├──→ Phase 2 (US1 — Formulário) ──→ Phase 4 (US3 — Admin service)
│ │
│ └──→ Phase 3 (US2 — Links de entrada)
└──→ Phase 5 (US4 — Conteúdo institucional, extensão da Phase 2)
└──→ Phase 6 (Polish)
```
- **Phase 1**: Sem dependências — começa imediatamente
- **Phase 2**: T007 e T008 podem começar em paralelo com Phase 1 (sem necessidade do backend para criar os arquivos TS); T009 depende de T007 + T008; T010 depende de T009
- **Phase 3**: T011 e T012 são paralelos entre si e independentes do backend; dependem apenas de T010 (rota já existir no App.tsx)
- **Phase 4**: T013 depende de T007/T008 (padrão do serviço) e do endpoint já implementado em T004
- **Phase 5**: T014 e T015 são modificações em JobsPage.tsx criado em T009 — devem ser feitas sequencialmente em relação a T009
- **Phase 6**: Depende de todas as fases anteriores
### Dependências por task
| Task | Depende de | Pode ir em paralelo com |
|------|----------------|------------------------|
| T001 | — | T003 |
| T002 | T001 | T003 |
| T003 | — | T001, T002 |
| T004 | T002, T003 | — |
| T005 | T002, T004 | — |
| T006 | T005 | — |
| T007 | — | T001T006, T008, T011, T012 |
| T008 | T007 | T011, T012 |
| T009 | T007, T008 | T011, T012 |
| T010 | T009 | T011, T012 |
| T011 | T010 | T012 |
| T012 | T010 | T011 |
| T013 | T007, T008 | T011, T012 |
| T014 | T009 | T013 |
| T015 | T014 | T013 |
| T016 | T006, T015 | — |
---
## Parallel Execution Examples
### Fluxo MVP (US1 apenas — Phase 1 + Phase 2)
```
Stream A (Backend): T001 → T002 → T004 → T005 → T006
Stream B (Schemas): T003 (paralelo a T001-T002)
Stream C (Frontend): T007 → T008 → T009 → T010
```
### Fluxo completo
```
Stream A (Backend): T001 → T002 → T004 → T005 → T006
Stream B (Schemas): T003
Stream C (Frontend): T007 → T008 → T009 → T010 → T014 → T015
Stream D (Links): T011 (paralelo após T010)
Stream E (Links): T012 (paralelo após T010)
Stream F (Admin svc): T013 (paralelo após T008)
```
---
## Implementation Strategy
**MVP Scope** (Phase 1 + Phase 2): Formulário público funcional com persistência — entrega o núcleo da feature (US1 P1).
**Incremento 2** (Phase 3): Links de descoberta — sem novos arquivos backend, apenas modificações pontuais em Footer e AgentsPage (US2 P1).
**Incremento 3** (Phase 4): Serviço admin no frontend — prepara consumo da listagem (US3 P2); página admin React adiada para iteração futura.
**Incremento 4** (Phase 5): Conteúdo institucional (hero + cards) sobre a base já existente de JobsPage (US4 P3).

View file

@ -0,0 +1,36 @@
# Specification Quality Checklist: Revisão UX/UI — Área do Cliente
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-22
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec gerada a partir do UX audit `ux-audit.md` existente no mesmo diretório. Todos os itens aprovados na primeira validação.
- Remoção de BoletosPage é apenas frontend; backend/model preservados — documentado em Assumptions.
- Pronto para `/speckit.plan`.

View file

@ -0,0 +1,118 @@
# Implementation Plan: Revisao UX/UI - Area do Cliente
**Branch**: `029-ux-area-do-cliente` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/029-ux-area-do-cliente/spec.md`
## Summary
Esta feature simplifica a area do cliente removendo etapas de baixo valor (dashboard e boletos no frontend), melhora a navegacao contextual (menu com 4 itens e iconografia SVG consistente), aumenta a qualidade de leitura de favoritos (thumbnail, preco e localizacao), adiciona a acao de cancelamento de visitas pendentes e cria a pagina Minha Conta com atualizacao de nome e troca de senha.
## Technical Context
**Language/Version**: Python 3.12 (backend) + TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, bcrypt (backend); React 18, react-router-dom v6, Axios, Tailwind CSS 3.4 (frontend)
**Storage**: PostgreSQL 16 (sem novas tabelas nesta feature)
**Testing**: `pytest` para backend; `npm run build` para frontend; checklist manual de UX
**Target Platform**: SPA web responsiva (desktop/mobile)
**Project Type**: web app full-stack com foco principal em UX frontend
**Performance Goals**: navegacao da area do cliente sem etapa intermediaria e atualizacoes sem reload para acoes principais
**Constraints**: nao alterar schemas do banco; manter auth JWT existente; manter backend de boletos intacto
**Scale/Scope**: rotas e componentes da area do cliente + 3 endpoints PATCH no backend
## Constitution Check
| Principio | Status | Observacao |
|---|---|---|
| Design-First | PASS | Mudancas focadas em fluxo, hierarquia visual e consistencia de icones |
| Separation of Concerns | PASS | Regras de dominio no backend e comportamento de tela no frontend |
| Spec-Driven | PASS | Plano derivado de `spec.md`, `ux-audit.md` e `tasks.md` |
| Data Integrity | PASS | Sem migrations de schema; update em entidades existentes |
| Security | PASS | Endpoints novos protegidos por JWT e ownership checks |
| Simplicity First | PASS | Remove telas redundantes e reduz friccao de navegacao |
## Project Structure
### Documentation (this feature)
```text
specs/029-ux-area-do-cliente/
├── plan.md
├── spec.md
├── tasks.md
├── ux-audit.md
└── checklists/
```
### Source Code (repository root)
```text
backend/
└── app/
├── routes/client_area.py
└── schemas/client_area.py
frontend/
└── src/
├── App.tsx
├── layouts/ClientLayout.tsx
├── contexts/AuthContext.tsx
├── services/clientArea.ts
├── types/clientArea.ts
└── pages/client/
├── FavoritesPage.tsx
├── VisitsPage.tsx
├── ComparisonPage.tsx
└── ProfilePage.tsx
```
**Structure Decision**: manter estrutura web existente, com mudancas localizadas em rotas/schemas backend e paginas/layouts da area do cliente no frontend.
## Implementation Approach
### 1. Roteamento e navegacao
- Redirecionar index da area do cliente para favoritos.
- Remover rota e item visual de boletos no frontend.
- Introduzir rota Minha Conta e manter destaque de item ativo em desktop/mobile.
### 2. Favoritos com contexto visual
- Enriquecer payload de favoritos no backend com dados de card (`price`, `city`, `neighborhood`, `cover_photo_url`).
- Atualizar card de favoritos para exibicao direta desses dados com fallback de imagem.
- Manter remocao sem reload com comportamento otimista.
### 3. Visitas com acao de cancelamento
- Exibir botao de cancelamento apenas para `status=pending`.
- Criar endpoint de cancelamento com validacao de ownership e estado atual.
- Aplicar update otimista no frontend com rollback em erro.
### 4. Minha Conta
- Criar tela `/area-do-cliente/conta` com formulario de nome e formulario de senha.
- Criar endpoints `PATCH /me/profile` e `PATCH /me/password`.
- Expor `updateUser` no `AuthContext` para refletir nome atualizado imediatamente.
### 5. Qualidade visual e consistencia
- Trocar iconografia por SVG Heroicons no layout do cliente.
- Melhorar empty states e feedbacks de erro/sucesso nas principais interacoes.
## Rollout and Validation
### Executavel
- Frontend: `npm run build`
- Backend: `pytest` nos cenarios relevantes de cliente (quando disponiveis)
### Manual
- Verificar redirecionamento `/area-do-cliente -> /area-do-cliente/favoritos`
- Verificar ausencia de menu/rota de boletos no frontend
- Verificar card de favorito com imagem/preco/localizacao
- Verificar cancelamento de visita pendente e bloqueio para estados nao pendentes
- Verificar atualizacao de nome e troca de senha em Minha Conta
## Complexity Tracking
Nenhuma excecao arquitetural requerida para esta feature.

View file

@ -0,0 +1,203 @@
# Feature Specification: Revisão UX/UI — Área do Cliente
**Feature Branch**: `029-ux-area-do-cliente`
**Created**: 2026-04-22
**Status**: Draft
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Navegar direto para Favoritos ao acessar a Área do Cliente (Priority: P1)
O cliente autenticado digita ou clica no link da Área do Cliente e chega imediatamente à lista de imóveis favoritos, sem passar por uma tela de painel intermediária que não agrega valor.
**Why this priority**: Elimina um clique desnecessário presente hoje. É a mudança de maior impacto imediato na jornada do usuário e desbloqueio para todo o restante da área.
**Independent Test**: Acessar `/area-do-cliente` deve redirecionar para `/area-do-cliente/favoritos`. A página de Favoritos deve carregar de forma autônoma e ser completamente utilizável.
**Acceptance Scenarios**:
1. **Given** um cliente autenticado, **When** ele acessa `/area-do-cliente`, **Then** é redirecionado automaticamente para `/area-do-cliente/favoritos`.
2. **Given** um cliente autenticado, **When** ele acessa `/area-do-cliente/boletos`, **Then** recebe uma resposta 404 (rota inexistente).
3. **Given** um cliente autenticado, **When** ele visualiza o menu lateral, **Then** os itens "Painel" e "Boletos" não estão presentes.
---
### User Story 2 — Visualizar favoritos com imagem, preço e localização (Priority: P1)
O cliente vê seus imóveis favoritos exibidos em cards que mostram a thumbnail da propriedade, o preço e a cidade/bairro, permitindo identificar cada imóvel sem precisar clicar para abrir.
**Why this priority**: É a página mais acessada da área do cliente. Sem imagem e preço, o usuário não consegue distinguir os imóveis e a experiência é frustrante.
**Independent Test**: Com ao menos um favorito salvo, a página deve exibir cards com imagem (ou placeholder), preço formatado e localização.
**Acceptance Scenarios**:
1. **Given** um cliente com favoritos salvos, **When** ele acessa `/area-do-cliente/favoritos`, **Then** cada card exibe thumbnail da propriedade (ou placeholder se sem foto), título, preço e cidade/bairro.
2. **Given** um cliente sem favoritos, **When** ele acessa a página, **Then** vê um empty state informativo com link para `/imoveis`.
3. **Given** um cliente visualizando favoritos, **When** ele clica em "Ver imóvel", **Then** é levado à página de detalhes do imóvel.
4. **Given** um cliente visualizando favoritos, **When** ele remove um favorito, **Then** o card some da lista sem recarregar a página.
---
### User Story 3 — Cancelar visita agendada (Priority: P2)
O cliente pode solicitar o cancelamento de uma visita com status pendente diretamente pela Área do Cliente, sem precisar entrar em contato por outro canal.
**Why this priority**: Resolve um ponto de atrito real: hoje o usuário não tem como cancelar uma visita pelo sistema, o que gera contato desnecessário via WhatsApp ou telefone.
**Independent Test**: Com uma visita no status "pendente", o botão "Cancelar" deve ser exibido e, ao clicar, alterar o status para "cancelado".
**Acceptance Scenarios**:
1. **Given** uma visita com `status=pending`, **When** o cliente acessa `/area-do-cliente/visitas`, **Then** o card exibe o botão "Cancelar".
2. **Given** o cliente clica em "Cancelar", **When** a ação é confirmada, **Then** o status do card muda para "cancelado" e o botão desaparece.
3. **Given** uma visita com status diferente de `pending` (ex: confirmada, cancelada), **When** o cliente visualiza o card, **Then** o botão "Cancelar" não está disponível.
4. **Given** uma falha de rede ao cancelar, **When** a requisição retorna erro, **Then** o cliente vê uma mensagem de erro e o status não muda.
---
### User Story 4 — Editar perfil e trocar senha (Priority: P2)
O cliente pode acessar `/area-do-cliente/conta`, visualizar seus dados cadastrais, atualizar o nome e alterar a senha, tudo dentro da Área do Cliente.
**Why this priority**: Hoje não existe nenhuma forma do cliente atualizar seus dados pelo sistema. É uma funcionalidade básica esperada em qualquer área logada.
**Independent Test**: A página `/area-do-cliente/conta` deve existir com formulário funcional de atualização de nome e de troca de senha.
**Acceptance Scenarios**:
1. **Given** o cliente acessa `/area-do-cliente/conta`, **When** a página carrega, **Then** exibe o nome atual preenchido e o e-mail em campo somente leitura.
2. **Given** o cliente altera o nome e clica em "Salvar", **When** o nome é válido (não vazio), **Then** vê confirmação de sucesso e o nome atualizado no menu lateral.
3. **Given** o cliente preenche "Senha atual", "Nova senha" e "Confirmar nova senha", **When** as senhas coincidem e têm pelo menos 8 caracteres, **Then** a senha é alterada e uma mensagem de sucesso é exibida.
4. **Given** o cliente informa uma senha atual incorreta, **When** submete o form, **Then** vê mensagem de erro "Senha atual incorreta" sem alterar nada.
5. **Given** "Nova senha" e "Confirmar nova senha" divergem, **When** o cliente tenta salvar, **Then** vê validação inline "As senhas não coincidem" antes de enviar ao servidor.
---
### User Story 5 — Navegação com ícones consistentes e item "Minha conta" (Priority: P3)
O cliente vê o menu lateral (desktop) e a barra de navegação (mobile) com ícones vetoriais consistentes em qualquer sistema operacional, e um link "Minha conta" que leva à nova página de perfil.
**Why this priority**: Melhoria visual e de consistência; não bloqueia funcionalidades principais, mas impacta a percepção de qualidade do produto.
**Independent Test**: O menu deve renderizar 4 itens (Favoritos, Comparar, Visitas, Minha conta) com ícones SVG visíveis e corretos em temas claro e escuro.
**Acceptance Scenarios**:
1. **Given** o cliente está na Área do Cliente, **When** visualiza o menu lateral ou a barra mobile, **Then** os 4 itens (Favoritos, Comparar, Visitas, Minha conta) estão presentes com ícones SVG.
2. **Given** o cliente está em qualquer página da área do cliente, **When** observa o item de menu correspondente à página atual, **Then** o item está visualmente destacado (ativo).
3. **Given** o cliente está em mobile, **When** acessa a barra de navegação, **Then** os 4 itens estão centralizados sem scroll horizontal desnecessário.
4. **Given** o cliente clica em "Sair", **When** a ação é executada, **Then** o ícone do botão é o ícone de logout (seta saindo de uma porta), não uma seta genérica.
---
### User Story 6 — Empty state explicativo no Comparar (Priority: P3)
O cliente que acessa a página de comparação sem imóveis selecionados recebe uma instrução clara sobre como adicionar imóveis à comparação.
**Why this priority**: Melhoria de usabilidade pontual; sem ela o usuário fica confuso, mas o impacto é menor que os itens anteriores.
**Independent Test**: Acessar `/area-do-cliente/comparar` sem imóveis selecionados deve exibir o empty state com a instrução de uso.
**Acceptance Scenarios**:
1. **Given** o cliente não tem imóveis na comparação, **When** acessa `/area-do-cliente/comparar`, **Then** vê uma mensagem explicando como adicionar imóveis (ex: "Acesse um imóvel e clique em 'Comparar' para adicioná-lo aqui").
2. **Given** o cliente tem imóveis na comparação, **When** acessa a página, **Then** a tabela de comparação é exibida normalmente.
---
### Edge Cases
- O que acontece se a foto de um imóvel favorito for removida após ser salvo? → O card exibe um placeholder de imagem.
- O que acontece se o cliente tentar cancelar uma visita já cancelada por outra aba? → O servidor retorna erro e o frontend exibe mensagem informativa.
- O que acontece se o cliente enviar o form de perfil com nome vazio? → Validação inline impede o envio antes de chegar ao servidor.
- O que acontece se o cliente sem favoritos tentar acessar diretamente `/area-do-cliente`? → Redireciona para `/area-do-cliente/favoritos` e exibe o empty state de favoritos.
- O que acontece se a sessão expirar durante uso da área do cliente? → O cliente é redirecionado para a tela de login com mensagem de sessão expirada.
---
## Requirements *(mandatory)*
### Functional Requirements
**Roteamento e Estrutura**
- **FR-001**: O sistema DEVE redirecionar `/area-do-cliente` para `/area-do-cliente/favoritos` de forma automática.
- **FR-002**: A rota `/area-do-cliente/boletos` DEVE ser removida; acessá-la DEVE retornar 404.
- **FR-003**: O menu de navegação da Área do Cliente DEVE conter exatamente 4 itens: Favoritos, Comparar, Visitas e Minha conta.
- **FR-004**: Os ícones do menu DEVE ser SVG vetoriais (Heroicons 2.0 outline), sem uso de emoji ou caracteres Unicode.
**Favoritos**
- **FR-005**: Cada card de favorito DEVE exibir: thumbnail da propriedade (ou placeholder padronizado), título, preço formatado em reais e cidade/bairro.
- **FR-006**: A thumbnail DEVE ser obtida a partir dos dados da propriedade (campo `cover_photo` ou primeira foto da galeria).
- **FR-007**: O card DEVE oferecer as ações "Ver imóvel" (navega para detalhe) e "Remover dos favoritos" (remove sem recarregar a página).
**Visitas**
- **FR-008**: O card de visita DEVE exibir: título do imóvel em destaque, data agendada/solicitada em destaque, badge de status alinhado à direita e mensagem como texto secundário.
- **FR-009**: Visitas com `status=pending` DEVE exibir botão "Cancelar".
- **FR-010**: Ao confirmar o cancelamento, o sistema DEVE chamar `PATCH /me/visits/:id/cancel` e atualizar o status do card para "cancelado" sem recarregar a página.
- **FR-011**: Visitas com status diferente de `pending` NÃO DEVEM exibir o botão "Cancelar".
**Comparar**
- **FR-012**: Quando não há imóveis selecionados para comparação, a página DEVE exibir um empty state com instrução clara de como adicionar imóveis à comparação.
**Perfil / Minha conta**
- **FR-013**: A rota `/area-do-cliente/conta` DEVE ser criada e acessível pelo menu "Minha conta".
- **FR-014**: A página de conta DEVE exibir o nome atual do cliente em campo editável e o e-mail em campo somente leitura.
- **FR-015**: O cliente DEVE poder atualizar o nome via `PATCH /me/profile`; o campo nome é obrigatório.
- **FR-016**: O cliente DEVE poder trocar a senha informando senha atual, nova senha e confirmação via `PATCH /me/password`.
- **FR-017**: A nova senha DEVE ter no mínimo 8 caracteres; a confirmação DEVE ser idêntica à nova senha; validações DEVEM ocorrer no frontend antes do envio.
- **FR-018**: Se a senha atual informada estiver incorreta, o servidor DEVE retornar erro e o frontend DEVE exibir "Senha atual incorreta".
**Backend — Novos Endpoints**
- **FR-019**: `PATCH /me/profile` — atualiza o nome do cliente autenticado. Requer autenticação JWT. Retorna os dados atualizados.
- **FR-020**: `PATCH /me/password` — altera a senha do cliente autenticado. Valida senha atual antes de persistir. Requer autenticação JWT.
- **FR-021**: `PATCH /me/visits/:id/cancel` — cancela uma visita com `status=pending` pertencente ao cliente autenticado. Retorna a visita atualizada. Requer autenticação JWT.
- **FR-022**: Tentativa de cancelar visita com status diferente de `pending` DEVE retornar erro com mensagem descritiva.
- **FR-023**: Tentativa de cancelar visita de outro cliente DEVE retornar 403 Forbidden.
**Layout e Mobile**
- **FR-024**: O botão "Sair" DEVE usar o ícone ArrowRightOnRectangle (Heroicons) no lugar do caractere `→`.
- **FR-025**: Em viewport mobile, a barra de navegação DEVE centralizar os 4 itens e indicar visualmente o item ativo.
### Key Entities
- **ClientUser**: Usuário da área do cliente. Atributos relevantes: `id`, `name`, `email`, `password_hash`. Possui coleções de favoritos e visitas.
- **SavedProperty (Favorito)**: Associação entre `ClientUser` e `Property`. Atributos relevantes: `id`, `client_user_id`, `property_id`, `created_at`.
- **VisitRequest (Visita)**: Solicitação de visita feita por um `ClientUser` para uma `Property`. Atributos relevantes: `id`, `client_user_id`, `property_id`, `scheduled_date`, `status` (pending | confirmed | cancelled), `message`.
- **Property (Imóvel)**: Imóvel do catálogo. Atributos consumidos nesta feature: `id`, `title`, `price`, `city`, `neighborhood`, `cover_photo` (ou galeria de fotos).
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: O cliente chega ao conteúdo real (Favoritos) com um clique a menos do que hoje — a página de painel não existe mais como etapa intermediária.
- **SC-002**: O cliente consegue identificar visualmente seus imóveis favoritos sem precisar abrir nenhum deles — cada card mostra imagem, preço e localização.
- **SC-003**: O cliente consegue cancelar uma visita pendente em menos de 3 cliques a partir da página de Visitas, sem sair da Área do Cliente.
- **SC-004**: O cliente consegue atualizar nome ou senha em uma única interação com o formulário de conta, sem precisar entrar em contato com suporte.
- **SC-005**: Os ícones do menu são renderizados de forma idêntica em Windows, macOS e Linux, tanto em tema claro quanto escuro.
- **SC-006**: A taxa de abandono da Área do Cliente (saída imediata) deve ser reduzida, dado que o primeiro conteúdo exibido passa a ser imediatamente útil.
- **SC-007**: Todos os 3 novos endpoints do backend respondem corretamente com autenticação válida e retornam erros descritivos para entradas inválidas.
---
## Assumptions
- A tabela `client_users` já existe no banco com as colunas `id`, `name`, `email` e `password_hash`; nenhuma migration de schema é necessária para os endpoints de perfil e senha.
- A tabela `visit_requests` já possui a coluna `status` com os valores `pending`, `confirmed` e `cancelled`; o endpoint de cancelamento apenas atualiza este campo.
- O backend de favoritos já expõe o `property_id`; a foto do imóvel será obtida do campo `cover_photo` da tabela de propriedades ou do primeiro item retornado pela API de fotos já existente.
- O sistema de autenticação JWT para clientes já está operacional; os novos endpoints reutilizarão o mesmo middleware de autenticação.
- A remoção de `BoletosPage` é apenas frontend e de rota; o model e os dados de boletos no banco são mantidos intactos para uso futuro pelo admin.
- O componente de comparação já armazena os imóveis selecionados em estado local ou contexto; esta feature não altera o mecanismo de seleção, apenas o empty state.
- Não há requisito de confirmação por e-mail para troca de senha nesta fase (fluxo simplificado: validar senha atual → salvar nova senha).
- A feature não inclui upload de foto de perfil do cliente.

View file

@ -0,0 +1,994 @@
# Tasks — Feature 029: Revisão UX/UI da Área do Cliente
**Branch**: `029-ux-area-do-cliente`
**Gerado em**: 2026-04-22
**Status**: Ready for implementation
---
## Fase 1 — Backend: Schemas
### T01 — Adicionar `PropertyCard` e schemas de profile/senha em `client_area.py`
**Arquivo**: `backend/app/schemas/client_area.py`
**Contexto**: O schema `PropertyBrief` só carrega `id`, `title`, `slug`. Os cards de favoritos precisam de `price`, `city`, `neighborhood` e `cover_photo_url`. Além disso, os endpoints de perfil e senha precisam de schemas de entrada/saída que não existem.
**Passos**:
1. Abrir `backend/app/schemas/client_area.py`.
2. Adicionar import `from typing import Optional` (já existe) e `from decimal import Decimal` (já existe).
3. Adicionar o schema `PropertyCard` logo após `PropertyBrief`:
```python
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":
"""Constrói PropertyCard a partir de um ORM Property."""
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,
)
```
4. Alterar `SavedPropertyOut` para usar `PropertyCard` em vez de `PropertyBrief`:
```python
class SavedPropertyOut(BaseModel):
id: str
property_id: Optional[str]
property: Optional[PropertyCard] # era PropertyBrief
created_at: datetime
model_config = {"from_attributes": True}
```
5. Adicionar os schemas de profile e senha ao final do arquivo:
```python
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
```
**Critérios de conclusão**:
- [ ] `PropertyCard` existe em `client_area.py` com os campos: `id`, `title`, `slug`, `price`, `city`, `neighborhood`, `cover_photo_url`.
- [ ] `PropertyCard.from_property()` extrai `photos[0].url` como cover e `city.name` / `neighborhood.name` dos relacionamentos ORM.
- [ ] `SavedPropertyOut.property` usa `PropertyCard` (não mais `PropertyBrief`).
- [ ] `UpdateProfileIn`, `UpdateProfileOut` e `UpdatePasswordIn` existem com as validações descritas.
---
## Fase 2 — Backend: Endpoints
### T02 — Adicionar eager load de fotos na query de favoritos
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: `SavedProperty.property` usa `lazy="joined"`, mas `Property.photos` usa `lazy="select"`. Sem `selectinload`, cada card de favorito dispara uma query extra para buscar as fotos (N+1). Precisa carregar as fotos junto com os favoritos.
**Passos**:
1. Adicionar import no topo do arquivo:
```python
from sqlalchemy.orm import selectinload
from app.models.property import Property as PropertyModel
```
2. Localizar a função `get_favorites` (rota `GET /favorites`).
3. Substituir a query atual:
```python
# Antes:
saved = (
SavedProperty.query.filter_by(user_id=g.current_user_id)
.order_by(SavedProperty.created_at.desc())
.all()
)
```
Por:
```python
# Depois:
saved = (
SavedProperty.query
.filter_by(user_id=g.current_user_id)
.options(selectinload(SavedProperty.property).selectinload(PropertyModel.photos))
.order_by(SavedProperty.created_at.desc())
.all()
)
```
4. Atualizar os imports de schemas no topo do arquivo — `SavedPropertyOut` agora serializa `PropertyCard`; não é necessária mudança de código aqui pois o schema foi alterado em T01, mas verificar que `PropertyCard` está disponível via `SavedPropertyOut`.
**Critérios de conclusão**:
- [ ] A query de favoritos usa `selectinload` para `property``photos`.
- [ ] O endpoint `GET /me/favorites` retorna o campo `property.cover_photo_url` com a URL da primeira foto (ou `null`).
- [ ] O endpoint retorna `property.price`, `property.city`, `property.neighborhood`.
- [ ] Testado manualmente: chamada para `GET /api/me/favorites` retorna JSON com campos `price`, `city`, `neighborhood`, `cover_photo_url` no objeto `property`.
---
### T03 — Implementar `PATCH /me/profile`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve atualizar `ClientUser.name` do usuário autenticado.
**Passos**:
1. Adicionar import:
```python
import bcrypt
from app.models.user import ClientUser
from app.schemas.client_area import UpdateProfileIn, UpdateProfileOut, UpdatePasswordIn
```
2. Adicionar ao final do arquivo (antes de qualquer `if __name__`):
```python
@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
```
**Critérios de conclusão**:
- [ ] `PATCH /api/me/profile` com `{ "name": "Novo Nome" }` retorna `200` com `{ id, name, email }`.
- [ ] `PATCH /api/me/profile` com `{ "name": "" }` retorna `422`.
- [ ] `PATCH /api/me/profile` sem token retorna `401`.
---
### T04 — Implementar `PATCH /me/password`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve verificar senha atual com bcrypt antes de gravar a nova.
**Passos**:
1. Garantir que `bcrypt` e `ClientUser` estão importados (feito em T03).
2. Adicionar ao final do arquivo:
```python
@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
# Verifica senha atual
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
```
**Critérios de conclusão**:
- [ ] `PATCH /api/me/password` com senha atual correta e nova senha ≥ 8 chars retorna `204`.
- [ ] `PATCH /api/me/password` com senha atual incorreta retorna `400` com `"Senha atual incorreta"`.
- [ ] `PATCH /api/me/password` com nova senha < 8 chars retorna `422`.
- [ ] `PATCH /api/me/password` sem token retorna `401`.
---
### T05 — Implementar `PATCH /me/visits/<id>/cancel`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve cancelar visita com `status=pending` do usuário autenticado.
**Passos**:
1. Adicionar ao final do arquivo:
```python
@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 visit.user_id != 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
```
**Critérios de conclusão**:
- [ ] `PATCH /api/me/visits/<id>/cancel` com visita `status=pending` do próprio usuário retorna `200` com `status: "cancelled"`.
- [ ] Cancelar visita com `status=confirmed` retorna `400`.
- [ ] Cancelar visita de outro usuário retorna `403`.
- [ ] Cancelar visita inexistente retorna `404`.
- [ ] `VisitRequestOut` está importado (já estava) e serializa o resultado corretamente.
---
## Fase 3 — Frontend: Tipos e Serviços
### T06 — Atualizar tipos em `clientArea.ts`
**Arquivo**: `frontend/src/types/clientArea.ts`
**Contexto**: `SavedProperty.property` só tem `{ id, title, slug }`. Precisa incluir `price`, `city`, `neighborhood`, `cover_photo_url`. Adicionar também os tipos dos payloads de profile/senha.
**Passos**:
1. Substituir o tipo `SavedProperty`:
```typescript
export interface PropertyCard {
id: string;
title: string;
slug: string;
price: string | null; // Decimal serializado como string pelo backend
city: string | null;
neighborhood: string | null;
cover_photo_url: string | null;
}
export interface SavedProperty {
id: string;
property_id: string | null;
property: PropertyCard | null;
created_at: string;
}
```
2. Adicionar ao final do arquivo:
```typescript
export interface UpdateProfilePayload {
name: string;
}
export interface UpdateProfileResponse {
id: string;
name: string;
email: string;
}
export interface ChangePasswordPayload {
current_password: string;
new_password: string;
}
```
**Critérios de conclusão**:
- [ ] `SavedProperty.property` é `PropertyCard | null`.
- [ ] `PropertyCard` tem todos os 7 campos listados.
- [ ] `UpdateProfilePayload`, `UpdateProfileResponse` e `ChangePasswordPayload` estão exportados.
- [ ] Sem erros de TypeScript em arquivos que importam `SavedProperty`.
---
### T07 — Adicionar `updateProfile`, `changePassword` e `cancelVisit` em `clientArea.ts`
**Arquivo**: `frontend/src/services/clientArea.ts`
**Contexto**: O serviço atual tem `getFavorites`, `addFavorite`, `removeFavorite`, `getVisits`, `getBoletos`. Faltam os três novos métodos correspondentes aos endpoints criados em T03T05.
**Passos**:
1. Atualizar o import de tipos no topo:
```typescript
import type {
Boleto,
ChangePasswordPayload,
SavedProperty,
UpdateProfilePayload,
UpdateProfileResponse,
VisitRequest,
} from '../types/clientArea';
```
2. Adicionar ao final do arquivo:
```typescript
export async function updateProfile(
data: UpdateProfilePayload,
): Promise<UpdateProfileResponse> {
const response = await api.patch<UpdateProfileResponse>('/me/profile', data);
return response.data;
}
export async function changePassword(data: ChangePasswordPayload): Promise<void> {
await api.patch('/me/password', data);
}
export async function cancelVisit(visitId: string): Promise<VisitRequest> {
const response = await api.patch<VisitRequest>(`/me/visits/${visitId}/cancel`);
return response.data;
}
```
**Critérios de conclusão**:
- [ ] `updateProfile`, `changePassword` e `cancelVisit` são exportados de `clientArea.ts`.
- [ ] Tipos corretos: `updateProfile` retorna `Promise<UpdateProfileResponse>`, `cancelVisit` retorna `Promise<VisitRequest>`.
- [ ] Sem erros TypeScript no arquivo.
---
## Fase 4 — Frontend: AuthContext
### T08 — Expor `updateUser` no `AuthContext`
**Arquivo**: `frontend/src/contexts/AuthContext.tsx`
**Contexto**: `ProfilePage` (T10) precisará atualizar `user.name` no contexto após salvar com sucesso, para que o sidebar reflita imediatamente o novo nome sem reload. Atualmente `setUser` é interno ao Provider.
**Passos**:
1. Adicionar `updateUser` à interface `AuthContextValue`:
```typescript
interface AuthContextValue {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
login: (data: LoginCredentials) => Promise<void>
register: (data: RegisterCredentials) => Promise<void>
logout: () => void
updateUser: (partial: Partial<User>) => void // NOVO
}
```
2. Implementar `updateUser` dentro do `AuthProvider`, após a declaração de `logout`:
```typescript
const updateUser = useCallback((partial: Partial<User>) => {
setUser(prev => (prev ? { ...prev, ...partial } : prev))
}, [])
```
3. Adicionar `updateUser` ao objeto passado para `AuthContext.Provider`:
```typescript
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateUser, // NOVO
}}
```
**Critérios de conclusão**:
- [ ] `useAuth().updateUser({ name: "Novo Nome" })` atualiza `user.name` no contexto.
- [ ] O sidebar (`ClientLayout.tsx`) reflete o novo nome sem recarga de página ao chamar `updateUser`.
- [ ] Sem erros TypeScript no arquivo ou em consumidores de `useAuth`.
---
## Fase 5 — Frontend: Layout
### T09 — Refatorar `ClientLayout.tsx` com ícones SVG e nova navegação
**Arquivo**: `frontend/src/layouts/ClientLayout.tsx`
**Contexto**: O layout atual tem 5 itens de nav com emoji Unicode inconsistentes, inclui "Painel" e "Boletos", e o botão "Sair" usa `→`. A nova nav tem 4 itens com SVG Heroicons.
**Passos**:
1. Substituir o array `navItems` por componentes SVG inline para cada ícone. Criar 4 componentes SVG pequenos no topo do arquivo (ou inline no array), usando Heroicons 2.0 outline (24×24, `stroke="currentColor"`, `strokeWidth={1.5}`):
- **Favoritos**`HeartIcon` (coração vazio)
- **Comparar**`ScaleIcon` (balança/scale)
- **Visitas**`CalendarIcon` (calendário)
- **Minha conta**`UserCircleIcon` (usuário com círculo)
- **Logout**`ArrowRightOnRectangleIcon` (seta saindo de retângulo)
Exemplo de SVG inline para `HeartIcon`:
```tsx
function HeartIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
strokeWidth={1.5} stroke="currentColor" className="size-5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" />
</svg>
);
}
```
Criar funções análogas: `ScaleIcon`, `CalendarIcon`, `UserCircleIcon`, `ArrowRightOnRectangleIcon`.
2. Substituir `navItems` por:
```tsx
const navItems = [
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false, Icon: HeartIcon },
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false, Icon: ScaleIcon },
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false, Icon: CalendarIcon },
{ to: '/area-do-cliente/conta', label: 'Minha conta', end: false, Icon: UserCircleIcon },
];
```
Remover `adminNavItems` ou mantê-lo separado se o admin ainda precisar (não alterar funcionalidade admin).
3. No render de cada `NavLink` (sidebar e mobile), substituir `<span className="text-base">{item.icon}</span>` por `<item.Icon />`.
4. Substituir o botão de logout:
```tsx
{/* Antes */}
<span></span>Sair
{/* Depois */}
<ArrowRightOnRectangleIcon />Sair
```
5. **Mobile nav**: No bloco `lg:hidden`, os links agora têm 4 itens. Adicionar ícones SVG também na nav mobile e centralizar:
```tsx
<div className="flex flex-1 justify-center gap-1">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-xs transition ${
isActive
? 'bg-surface text-textPrimary font-medium'
: 'text-textSecondary hover:text-textPrimary'
}`
}
>
<item.Icon />
{item.label}
</NavLink>
))}
</div>
```
**Critérios de conclusão**:
- [ ] Menu lateral tem exatamente 4 itens: Favoritos, Comparar, Visitas, Minha conta.
- [ ] "Painel" e "Boletos" não aparecem no menu.
- [ ] Todos os ícones são SVG `<svg>` inline (sem emoji, sem texto Unicode).
- [ ] Botão "Sair" usa `ArrowRightOnRectangleIcon`.
- [ ] Item ativo está visualmente destacado em ambos: sidebar (desktop) e barra (mobile).
- [ ] Mobile: 4 itens centralizados sem scroll horizontal.
- [ ] Sem erros TypeScript/JSX no arquivo.
---
## Fase 6 — Frontend: Páginas
### T10 — Criar `ProfilePage.tsx`
**Arquivo**: `frontend/src/pages/client/ProfilePage.tsx` *(criar)*
**Contexto**: Página inexistente. Deve exibir dois formulários independentes: edição de nome e troca de senha.
**Passos**:
1. Criar o arquivo com a seguinte estrutura:
```tsx
import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { changePassword, updateProfile } from '../../services/clientArea';
export default function ProfilePage() {
const { user, updateUser } = useAuth();
// — Form de perfil —
const [name, setName] = useState(user?.name ?? '');
const [nameError, setNameError] = useState('');
const [nameSaving, setNameSaving] = useState(false);
const [nameSuccess, setNameSuccess] = useState(false);
// — Form de senha —
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordSuccess, setPasswordSuccess] = useState(false);
async function handleSaveName(e: React.FormEvent) {
e.preventDefault();
setNameError('');
setNameSuccess(false);
if (!name.trim()) {
setNameError('O nome não pode ser vazio.');
return;
}
setNameSaving(true);
try {
const updated = await updateProfile({ name: name.trim() });
updateUser({ name: updated.name });
setNameSuccess(true);
} catch {
setNameError('Erro ao salvar. Tente novamente.');
} finally {
setNameSaving(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordError('');
setPasswordSuccess(false);
if (newPassword.length < 8) {
setPasswordError('A nova senha deve ter pelo menos 8 caracteres.');
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('As senhas não coincidem.');
return;
}
setPasswordSaving(true);
try {
await changePassword({ current_password: currentPassword, new_password: newPassword });
setPasswordSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: any) {
const msg = err?.response?.data?.error ?? 'Erro ao alterar senha.';
setPasswordError(msg);
} finally {
setPasswordSaving(false);
}
}
return (
<div className="p-6 max-w-lg space-y-8">
<h1 className="text-xl font-semibold text-textPrimary">Minha conta</h1>
{/* Formulário: dados pessoais */}
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
<h2 className="text-sm font-semibold text-textPrimary">Dados pessoais</h2>
<form onSubmit={handleSaveName} className="space-y-4">
<div>
<label className="block text-xs text-textSecondary mb-1">Nome</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">E-mail</label>
<input
type="email"
value={user?.email ?? ''}
readOnly
className="w-full rounded-lg border border-borderSubtle bg-surface px-3 py-2 text-sm text-textTertiary cursor-not-allowed"
/>
</div>
{nameError && <p className="text-xs text-red-400">{nameError}</p>}
{nameSuccess && <p className="text-xs text-green-400">Nome atualizado com sucesso!</p>}
<button
type="submit"
disabled={nameSaving}
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition"
>
{nameSaving ? 'Salvando…' : 'Salvar alterações'}
</button>
</form>
</section>
{/* Formulário: trocar senha */}
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
<h2 className="text-sm font-semibold text-textPrimary">Alterar senha</h2>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-xs text-textSecondary mb-1">Senha atual</label>
<input
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">Nova senha</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
<div>
<label className="block text-xs text-textSecondary mb-1">Confirmar nova senha</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
/>
</div>
{passwordError && <p className="text-xs text-red-400">{passwordError}</p>}
{passwordSuccess && <p className="text-xs text-green-400">Senha alterada com sucesso!</p>}
<button
type="submit"
disabled={passwordSaving}
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition"
>
{passwordSaving ? 'Salvando…' : 'Alterar senha'}
</button>
</form>
</section>
</div>
);
}
```
**Critérios de conclusão**:
- [ ] Página renderiza sem erros.
- [ ] Nome atual do usuário aparece pré-preenchido no campo "Nome".
- [ ] E-mail aparece em campo `readOnly` (não editável).
- [ ] Submit com nome vazio exibe erro inline sem chamar o servidor.
- [ ] Submit bem-sucedido de nome exibe "Nome atualizado com sucesso!" e o sidebar reflete o novo nome.
- [ ] Submit com `newPassword !== confirmPassword` exibe "As senhas não coincidem." sem chamar o servidor.
- [ ] Submit com `newPassword.length < 8` exibe erro inline.
- [ ] Senha atual incorreta → backend retorna `400` → frontend exibe "Senha atual incorreta".
- [ ] Após troca de senha bem-sucedida, campos de senha são limpos.
---
### T11 — Melhorar `FavoritesPage.tsx` com cards enriquecidos
**Arquivo**: `frontend/src/pages/client/FavoritesPage.tsx`
**Contexto**: Cards atuais mostram só título e link. Com T01/T02, o backend já entrega `cover_photo_url`, `price`, `city`, `neighborhood`. Precisa exibir essas informações e melhorar o empty state.
**Passos**:
1. Atualizar o import de tipos:
```typescript
import type { SavedProperty } from '../../types/clientArea';
```
2. Corrigir o tipo do estado: `useState<SavedProperty[]>([])`.
3. Remover o componente `HeartButton` deste contexto — substituir pela ação "Remover dos favoritos" usando `removeFavorite` do serviço:
```typescript
import { getFavorites, removeFavorite } from '../../services/clientArea';
```
4. Implementar a função de remoção (optimistic update):
```typescript
async function handleRemove(savedId: string, propertyId: string | null) {
if (!propertyId) return;
setFavorites(prev => prev.filter(f => f.id !== savedId));
try {
await removeFavorite(propertyId);
} catch {
// Recarregar em caso de erro
getFavorites().then(data => setFavorites(Array.isArray(data) ? data : []));
}
}
```
5. Substituir os cards no retorno JSX por:
```tsx
{favorites.map((item) => {
const prop = item.property;
const price = prop?.price
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 })
.format(parseFloat(prop.price))
: null;
const location = [prop?.neighborhood, prop?.city].filter(Boolean).join(', ');
return (
<div key={item.id} className="rounded-xl border border-borderSubtle bg-panel overflow-hidden hover:border-borderStandard transition">
{/* Thumbnail */}
<div className="relative h-40 bg-surface">
{prop?.cover_photo_url ? (
<img
src={prop.cover_photo_url}
alt={prop.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-textQuaternary text-xs">
Sem foto
</div>
)}
</div>
{/* Info */}
<div className="p-4">
<p className="text-sm font-medium text-textPrimary line-clamp-2">{prop?.title ?? 'Imóvel'}</p>
{price && <p className="mt-1 text-sm font-semibold text-brand">{price}</p>}
{location && <p className="mt-0.5 text-xs text-textTertiary">{location}</p>}
{/* Ações */}
<div className="mt-3 flex items-center gap-2">
<a
href={prop?.slug ? `/imoveis/${prop.slug}` : '#'}
className="flex-1 rounded-lg border border-borderSubtle px-3 py-1.5 text-center text-xs text-textSecondary hover:text-textPrimary hover:border-borderStandard transition"
>
Ver imóvel
</a>
<button
onClick={() => handleRemove(item.id, item.property_id)}
className="rounded-lg border border-borderSubtle px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 hover:border-red-500/20 transition"
>
Remover
</button>
</div>
</div>
</div>
);
})}
```
**Critérios de conclusão**:
- [ ] Cada card exibe: thumbnail (ou placeholder "Sem foto"), título, preço formatado em BRL, cidade/bairro.
- [ ] Botão "Remover" remove o card da lista imediatamente (optimistic update) e chama `removeFavorite`.
- [ ] Botão "Ver imóvel" navega para `/imoveis/<slug>`.
- [ ] Empty state exibe link para `/imoveis`.
- [ ] Sem erros TypeScript.
---
### T12 — Melhorar `VisitsPage.tsx` com cancelamento de visita
**Arquivo**: `frontend/src/pages/client/VisitsPage.tsx`
**Contexto**: Página só exibe visitas. Precisa adicionar botão "Cancelar" para `status=pending`, com confirmação simples e optimistic update.
**Passos**:
1. Adicionar import do serviço:
```typescript
import { cancelVisit, getVisits } from '../../services/clientArea';
```
2. Adicionar estado para controle de cancelamento:
```typescript
const [cancelling, setCancelling] = useState<string | null>(null); // visitId em progresso
const [cancelError, setCancelError] = useState<string | null>(null);
```
3. Implementar `handleCancel`:
```typescript
async function handleCancel(visitId: string) {
if (!window.confirm('Confirmar cancelamento desta visita?')) return;
setCancelling(visitId);
setCancelError(null);
// Optimistic update
setVisits(prev =>
prev.map(v => (v.id === visitId ? { ...v, status: 'cancelled' as const } : v))
);
try {
const updated = await cancelVisit(visitId);
setVisits(prev => prev.map(v => (v.id === visitId ? updated : v)));
} catch (err: any) {
// Reverter
setCancelError('Não foi possível cancelar. Tente novamente.');
getVisits().then(setVisits).catch(() => {});
} finally {
setCancelling(null);
}
}
```
4. Dentro do map de visitas, após o badge de status, adicionar o botão condicional:
```tsx
{visit.status === 'pending' && (
<button
onClick={() => handleCancel(visit.id)}
disabled={cancelling === visit.id}
className="mt-3 rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition"
>
{cancelling === visit.id ? 'Cancelando…' : 'Cancelar visita'}
</button>
)}
```
5. Exibir `cancelError` se presente, logo após o bloco de visitas:
```tsx
{cancelError && (
<p className="mt-2 text-xs text-red-400">{cancelError}</p>
)}
```
**Critérios de conclusão**:
- [ ] Visita com `status=pending` exibe botão "Cancelar visita".
- [ ] Ao clicar, `window.confirm` é exibido; se cancelado pelo usuário, nenhuma ação.
- [ ] Ao confirmar, o badge de status muda imediatamente para "Cancelada" (optimistic).
- [ ] O botão "Cancelar visita" desaparece após status mudar.
- [ ] Visitas com status diferente de `pending` não exibem o botão.
- [ ] Em caso de erro de rede, mensagem de erro é exibida e lista é recarregada.
---
### T13 — Melhorar empty state de `ComparisonPage.tsx`
**Arquivo**: `frontend/src/pages/client/ComparisonPage.tsx`
**Contexto**: O empty state atual diz "Nenhum imóvel selecionado para comparação" com link para `/imoveis`. Falta instrução clara de como usar a feature.
**Passos**:
1. Localizar o bloco do empty state (quando `properties.length === 0`).
2. Substituir o conteúdo interno do `div` de empty state por:
```tsx
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center max-w-sm mx-auto">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface">
{/* ScaleIcon SVG inline */}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
strokeWidth={1.5} stroke="currentColor" className="size-6 text-textTertiary">
<path strokeLinecap="round" strokeLinejoin="round"
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 15.95M5.25 4.97l-2.62 15.95m0 0a48.959 48.959 0 0 0 3.32.65M5.63 20.92a48.958 48.958 0 0 0 3.32-.65m9.63.65a48.952 48.952 0 0 0 3.32-.65" />
</svg>
</div>
<p className="text-sm font-medium text-textPrimary mb-2">Nenhum imóvel para comparar</p>
<p className="text-xs text-textTertiary mb-4">
Acesse um imóvel no catálogo e clique em{' '}
<span className="font-medium text-textSecondary">"Comparar"</span>{' '}
para adicioná-lo aqui. Você pode comparar até 3 imóveis lado a lado.
</p>
<Link
to="/imoveis"
className="inline-block rounded-lg bg-brand px-4 py-2 text-xs font-medium text-white hover:bg-brandHover transition"
>
Explorar imóveis
</Link>
</div>
```
**Critérios de conclusão**:
- [ ] Empty state exibe instrução explicando como adicionar imóveis à comparação.
- [ ] Instrução menciona o botão "Comparar" nos cards de imóvel.
- [ ] Link "Explorar imóveis" navega para `/imoveis`.
- [ ] Quando há imóveis na comparação, a tabela é exibida normalmente (sem regressão).
---
## Fase 7 — Frontend: Roteamento
### T14 — Atualizar rotas em `App.tsx`
**Arquivo**: `frontend/src/App.tsx`
**Contexto**: Precisa: (a) redirecionar `/area-do-cliente` para `/area-do-cliente/favoritos`; (b) remover a rota `/boletos`; (c) adicionar a rota `/conta` apontando para `ProfilePage`.
**Passos**:
1. Adicionar import de `Navigate` e `ProfilePage`:
```typescript
import { Navigate } from 'react-router-dom'; // adicionar ao import existente de react-router-dom
import ProfilePage from './pages/client/ProfilePage';
```
2. Remover os imports de `BoletosPage` e `ClientDashboardPage`:
```typescript
// Remover estas linhas:
import BoletosPage from './pages/client/BoletosPage';
import ClientDashboardPage from './pages/client/ClientDashboardPage';
```
3. Localizar o bloco de rotas da área do cliente e substituir:
```tsx
{/* Antes */}
<Route index element={<ClientDashboardPage />} />
<Route path="favoritos" element={<FavoritesPage />} />
<Route path="comparar" element={<ComparisonPage />} />
<Route path="visitas" element={<VisitsPage />} />
<Route path="boletos" element={<BoletosPage />} />
{/* Depois */}
<Route index element={<Navigate to="favoritos" replace />} />
<Route path="favoritos" element={<FavoritesPage />} />
<Route path="comparar" element={<ComparisonPage />} />
<Route path="visitas" element={<VisitsPage />} />
<Route path="conta" element={<ProfilePage />} />
```
**Critérios de conclusão**:
- [ ] Acessar `/area-do-cliente` redireciona para `/area-do-cliente/favoritos` (Replace — sem entrada no histórico).
- [ ] A rota `/area-do-cliente/boletos` não existe mais (retorna 404 do React Router).
- [ ] A rota `/area-do-cliente/conta` renderiza `ProfilePage`.
- [ ] Sem erros TypeScript/lint no arquivo.
---
## Fase 8 — Remoção de Arquivos Obsoletos
### T15 — Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`
**Arquivos a deletar**:
- `frontend/src/pages/client/BoletosPage.tsx`
- `frontend/src/pages/client/ClientDashboardPage.tsx`
**Contexto**: Após T14, nenhum import desses componentes existe mais no projeto. Removê-los evita confusão futura.
**Passos**:
1. Verificar que nenhum arquivo do projeto importa `BoletosPage` ou `ClientDashboardPage`:
```powershell
Select-String -Path "frontend/src/**" -Pattern "BoletosPage|ClientDashboardPage" -Recurse
```
2. Se o resultado for vazio (apenas os próprios arquivos), deletar:
```powershell
Remove-Item "frontend/src/pages/client/BoletosPage.tsx"
Remove-Item "frontend/src/pages/client/ClientDashboardPage.tsx"
```
**Critérios de conclusão**:
- [ ] Os dois arquivos não existem mais no projeto.
- [ ] Nenhum arquivo do projeto os importa.
- [ ] Build do frontend (ou `tsc --noEmit`) passa sem erros.
---
## Grafo de Dependências
```
T01 (Schemas backend)
└─ T02 (Eager load GET /favorites) → T11 (FavoritesPage)
└─ T03 (PATCH /profile) → T07 (cancelVisit/updateProfile) → T10 (ProfilePage)
└─ T04 (PATCH /password) ↗
└─ T05 (PATCH /visits/cancel) → T07 (cancelVisit) → T12 (VisitsPage)
T06 (Tipos TS) → T07 (Serviços) → T10, T11, T12
T08 (AuthContext.updateUser) → T10 (ProfilePage usa updateUser)
T09 (ClientLayout) — independente, pode ser feito em paralelo com T01T08
T13 (ComparisonPage empty state) — independente
T14 (App.tsx rotas) → depende de T10 (ProfilePage deve existir)
T15 (Deletar arquivos) → depende de T14
```
## Execução em Paralelo
| Grupo paralelo | Tasks |
|---|---|
| 1 (backend) | T01 → (T02, T03, T04, T05) em paralelo após T01 |
| 2 (frontend infra) | T06, T08, T09, T13 podem ser feitos em paralelo entre si |
| 3 (serviços) | T07 após T06 |
| 4 (páginas) | T10 após T03+T04+T08+T07; T11 após T02+T06+T07; T12 após T05+T06+T07 |
| 5 (finalização) | T14 após T10; T15 após T14 |
## Checklist Resumida
- [X] T01 [P] Schemas backend: `PropertyCard`, `UpdateProfileIn/Out`, `UpdatePasswordIn` em `backend/app/schemas/client_area.py`
- [X] T12 [US3] Cancelamento em `frontend/src/pages/client/VisitsPage.tsx`
- [X] T13 [P] [US6] Empty state em `frontend/src/pages/client/ComparisonPage.tsx`
- [X] T14 Rotas em `frontend/src/App.tsx`
- [X] T15 Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`

View file

@ -0,0 +1,173 @@
# UX/UI Audit — Área do Cliente (`/area-do-cliente`)
**Data:** 2026-04-22
**Auditor:** GitHub Copilot (UX/UI Review)
**Escopo:** Todas as páginas e componentes sob `/area-do-cliente`
---
## 1. Inventário de Telas Atuais
| Rota | Componente | Status |
|------|-----------|--------|
| `/area-do-cliente` | `ClientDashboardPage` | ❌ Remover |
| `/area-do-cliente/favoritos` | `FavoritesPage` | 🔧 Melhorar |
| `/area-do-cliente/comparar` | `ComparisonPage` | 🔧 Melhorar |
| `/area-do-cliente/visitas` | `VisitsPage` | 🔧 Melhorar |
| `/area-do-cliente/boletos` | `BoletosPage` | ❌ Remover |
---
## 2. Problemas Identificados
### 2.1 Dashboard (Painel) — REMOVER
- **Problema:** 3 cards com números (favoritos, visitas, boletos). Não agrega valor real ao usuário — ele precisa clicar de qualquer forma para ver os detalhes.
- **Impacto:** UX: Alto. Adiciona um clique desnecessário para chegar ao conteúdo real.
- **Decisão:** Remover o dashboard. Redirecionar `/area-do-cliente``/area-do-cliente/favoritos`.
### 2.2 Boletos — REMOVER
- **Problema:** Funcionalidade de boletos é uma feature de gestão imobiliária avançada raramente usada. A tabela existe mas os dados dependem de inserção manual pelo admin. Gera confusão para usuários sem boletos.
- **Impacto:** UX: Médio. Ruído visual no menu, expectativa não cumprida.
- **Decisão:** Remover a rota, o componente e o item do menu. Manter o backend (não excluir o model/API).
### 2.3 Navegação (Sidebar) — Ícones
- **Problema:** Ícones usam caracteres Unicode/emoji (`⊞`, `♡`, `⇄`, `📅`, `📄`) que rendem de forma inconsistente entre sistemas operacionais e navegadores. Look não-profissional em temas dark/light.
- **Impacto:** Visual: Alto. Inconsistência de rendering cross-platform.
- **Decisão:** Substituir por SVG inline (Heroicons 2.0 outline) para cada item de nav.
### 2.4 FavoritesPage — Cards sem imagem/preço
- **Problema:** Cards de favoritos mostram apenas título e link "Ver detalhes →". O usuário não consegue lembrar qual imóvel é qual sem clicar.
- **Impacto:** UX: Alto. Experiência frustrante para quem tem múltiplos favoritos.
- **Decisão:** Mostrar thumbnail da primeira foto, preço e cidade/bairro no card.
### 2.5 FavoritesPage — Layout de grid muito esparso
- **Problema:** Grid `1 → 2 → 3 colunas` com cards muito altos (h-48 skeleton) para conteúdo mínimo.
- **Decisão:** Cards com imagem real + info resumida, altura proporcional.
### 2.6 VisitsPage — Sem ação do usuário
- **Problema:** O usuário só visualiza visitas. Não há como cancelar, nem há indicação clara de próximos passos.
- **Impacto:** UX: Médio. Usuário precisa entrar em contato por outro canal para cancelar.
- **Decisão:** Adicionar botão "Solicitar cancelamento" para visitas com status `pending`.
### 2.7 VisitsPage — Layout de lista plana
- **Problema:** Cards de visita não têm hierarquia visual clara. Data e status competem com o título.
- **Decisão:** Refatorar card de visita com: título em destaque, data em destaque, status badge alinhado à direita, mensagem como texto secundário colapsável.
### 2.8 ComparisonPage — Empty state sem call-to-action útil
- **Problema:** Empty state tem apenas "Nenhum imóvel selecionado" e link para `/imoveis`. Usuário não sabe como adicionar imóveis à comparação.
- **Decisão:** Empty state explicativo com instrução de como usar a feature (tooltip/hint).
### 2.9 ClientLayout — Sidebar sem perfil editável
- **Problema:** Sidebar mostra avatar + nome + email mas sem link para perfil/configurações.
- **Impacto:** UX: Médio. Usuário não consegue alterar dados cadastrais pela área do cliente.
- **Decisão:** Adicionar link "Minha conta" apontando para nova rota `/area-do-cliente/conta`.
### 2.10 ClientLayout — Botão "Sair" sem ícone próprio
- **Problema:** Usa `→` como ícone de logout. Semântica errada (parece "ir para").
- **Decisão:** Substituir por ícone ArrowRightOnRectangle (Heroicons logout).
### 2.11 Mobile Nav — Scroll horizontal fraco
- **Problema:** Em mobile, a navegação é uma barra horizontal com scroll. Após remover boletos e painel, teremos 3 itens — cabe bem. Mas ainda carece de indicador visual claro de ativo.
- **Decisão:** Melhorar o indicador ativo com underline/pill e alinhar os 3 itens centralizados.
### 2.12 Ausência de página de perfil/conta
- **Problema:** Não existe `/area-do-cliente/conta`. Usuário não pode ver/editar nome, email ou senha.
- **Decisão:** Criar `ProfilePage` com form de atualização de nome e alteração de senha.
---
## 3. Resumo das Decisões
### ❌ Remover
- `ClientDashboardPage` (`/area-do-cliente`) — redirecionar para `/area-do-cliente/favoritos`
- `BoletosPage` (`/area-do-cliente/boletos`) — remover rota, nav item e componente
- Item "Boletos" e item "Painel" do nav
### ✅ Manter (com melhorias)
- `FavoritesPage` — melhorar cards com thumbnail + preço + localização
- `VisitsPage` — adicionar cancelamento + melhorar layout do card
- `ComparisonPage` — melhorar empty state
- `ClientLayout` — trocar ícones por SVG, adicionar link "Minha conta", melhorar logout
### Criar
- `ProfilePage` (`/area-do-cliente/conta`) — formulário de edição de perfil
---
## 4. Nova Estrutura de Navegação
**Antes (5 itens + Painel):**
```
⊞ Painel
♡ Favoritos
⇄ Comparar
📅 Visitas
📄 Boletos
```
**Depois (4 itens, sem Painel como item separado — é o redirect):**
```
[heart-icon] Favoritos
[scale-icon] Comparar
[calendar-icon] Visitas
[user-icon] Minha conta
```
---
## 5. Fluxo UX Revisado
```
/area-do-cliente → redirect 302 → /area-do-cliente/favoritos
/area-do-cliente/favoritos
Cards: thumbnail | título | preço | cidade
Ação: [Remover dos favoritos] [Ver imóvel →]
/area-do-cliente/visitas
Cards: título imóvel | data | status badge
Ação: [Solicitar cancelamento] (só para status=pending)
/area-do-cliente/comparar
Tabela: até 3 imóveis lado a lado
Empty state: instrução de como adicionar imóvel à comparação
/area-do-cliente/conta
Form: Nome | Email (readonly) | Senha atual | Nova senha | Confirmar senha
Ação: [Salvar alterações]
```
---
## 6. Componentes Afetados
| Arquivo | Ação |
|---------|------|
| `frontend/src/layouts/ClientLayout.tsx` | Refatorar nav (SVG icons, remover Boletos/Painel, adicionar Conta) |
| `frontend/src/pages/client/ClientDashboardPage.tsx` | Deletar ou converter em redirect |
| `frontend/src/pages/client/BoletosPage.tsx` | Deletar |
| `frontend/src/pages/client/FavoritesPage.tsx` | Melhorar cards |
| `frontend/src/pages/client/VisitsPage.tsx` | Melhorar card + adicionar cancelamento |
| `frontend/src/pages/client/ComparisonPage.tsx` | Melhorar empty state |
| `frontend/src/pages/client/ProfilePage.tsx` | Criar (novo) |
| `frontend/src/services/clientArea.ts` | Adicionar `updateProfile`, `changePassword`, `cancelVisit` |
| `frontend/src/App.tsx` | Atualizar rotas (remover boletos, adicionar conta, redirect) |
| `backend/app/routes/client.py` | Adicionar PATCH `/me/profile`, PATCH `/me/password`, PATCH `/me/visits/:id/cancel` |
| `backend/app/schemas/client.py` | Adicionar schemas de update |
---
## 7. Critérios de Aceite
- [ ] `/area-do-cliente` redireciona para `/area-do-cliente/favoritos`
- [ ] "Boletos" não aparece no menu e a rota 404
- [ ] "Painel" não aparece no menu
- [ ] Ícones do menu são SVG Heroicons consistentes em dark e light
- [ ] Card de favorito mostra: imagem (ou placeholder), título, preço, cidade
- [ ] Card de visita mostra: título, data agendada (ou solicitada), status badge
- [ ] Visita com `status=pending` exibe botão "Cancelar" que muda para `cancelled`
- [ ] Empty state do comparar explica como usar a feature
- [ ] `/area-do-cliente/conta` exibe form com nome e troca de senha
- [ ] Form de conta valida: nome obrigatório, senhas com mínimo 8 chars, confirmação igual
- [ ] Mobile nav centraliza os 4 itens sem scroll (ou scroll suave se necessário)
- [ ] Botão "Sair" usa ícone de logout correto

View file

@ -0,0 +1,191 @@
# UI Contract — Navbar do Topo
## Objetivo
Definir o comportamento observável da navbar por perfil de usuário, breakpoint e estado interativo, sem introduzir mudanças de API.
---
## 1. Perfis suportados
### `visitor`
Condição:
- `isAuthenticated === false`
Elementos obrigatórios:
- Logo com navegação para `/`
- Links públicos principais
- CTA `Anunciar imóvel` para `/cadastro-residencia`
- Ação `Entrar` para `/login`
- Ação de favoritos públicos quando aplicável ao estado atual do produto
Elementos proibidos:
- Menu `Admin`
- Seção `Minha Conta`
### `client`
Condição:
- `isAuthenticated === true`
- `user.role !== 'admin'`
Elementos obrigatórios:
- Tudo que continua relevante da navegação pública
- Gatilho de conta com inicial/avatar e primeiro nome truncado
- Entradas contextuais: `Favoritos`, `Comparar`, `Visitas`, `Minha conta`
- Ação `Sair` separada visualmente
Elementos proibidos:
- Menu `Admin`
- Botão `Entrar`
### `admin`
Condição:
- `isAuthenticated === true`
- `user.role === 'admin'`
Elementos obrigatórios:
- Tudo que continua relevante da navegação pública
- Gatilho contextual `Admin`
- Atalhos admin prioritários para módulos existentes
- Ação `Sair`
Elementos proibidos:
- Menu padrão `Minha Conta` de cliente
- Botão `Entrar`
---
## 2. Contrato desktop
### Estrutura mínima
```text
[Logo]
[Navegação pública principal]
[Ações contextuais por perfil]
[Theme toggle]
[CTA principal]
[Ação de autenticação ou logout]
```
### Regras
- Devem existir no máximo 5 links públicos visíveis simultaneamente.
- O CTA principal deve ter destaque visual acima dos links secundários.
- Menus contextuais (`Admin` e `Conta`) não podem deslocar ou truncar a navegação principal.
- Nomes longos de usuário devem truncar sem quebrar altura ou alinhamento.
---
## 3. Contrato mobile
### Gatilho hambúrguer
Obrigatório:
- `aria-label` dinâmico entre abrir/fechar
- `aria-expanded` coerente com o estado
- `aria-controls` apontando para o painel do menu
### Conteúdo do menu
Obrigatório:
- Mesmos destinos públicos principais em ordem lógica
- CTA `Anunciar imóvel`
- Seção `Minha Conta` apenas para cliente autenticado
- Seção `Admin` apenas para admin
- Logout apenas para usuário autenticado
- `Entrar` apenas para visitante
Regras:
- Alvos tocáveis com área mínima de 44x44 px.
- Ao navegar por qualquer item, o menu deve fechar.
- O menu mobile não pode coexistir com dropdown contextual desktop.
---
## 4. Contrato de estado interativo
Estados possíveis:
```text
closed
mobile-open
client-open
admin-open
```
Invariantes:
- Apenas um estado aberto por vez.
- Abrir um contexto fecha qualquer outro.
- Clique fora fecha o contexto aberto.
- Mudança de rota fecha qualquer contexto aberto.
- Logout fecha qualquer contexto aberto e restaura a visualização de visitante.
---
## 5. Contrato de acessibilidade
Obrigatório:
- `nav` com `aria-label` descritivo
- foco visível em links e botões
- acionamento por teclado em gatilhos interativos
- contraste adequado em texto, hover, active e estados abertos nos temas suportados
Não aceitável:
- gatilhos sem nome acessível
- estados visuais dependentes apenas de cor sem contraste suficiente
- foco invisível ou escondido pelo backdrop da navbar
---
## 6. Destinos atualmente suportados
### Navegação pública
| Rótulo | Destino atual |
|---|---|
| Comprar | `/imoveis?listing_type=venda` |
| Alugar | `/imoveis?listing_type=aluguel` |
| Equipe | `/corretores` |
| Sobre | `/sobre` |
| Contato | `/contato` |
| Anunciar imóvel | `/cadastro-residencia` |
| Entrar | `/login` |
### Navegação do cliente
| Rótulo | Destino atual |
|---|---|
| Favoritos | `/area-do-cliente/favoritos` |
| Comparar | `/area-do-cliente/comparar` |
| Visitas | `/area-do-cliente/visitas` |
| Minha conta | `/area-do-cliente/conta` |
### Navegação admin
| Rótulo | Destino atual |
|---|---|
| Imóveis | `/admin/properties` |
| Corretores | `/admin/corretores` |
| Clientes | `/admin/clientes` |
| Boletos | `/admin/boletos` |
| Visitas | `/admin/visitas` |
| Favoritos | `/admin/favoritos` |
| Cidades | `/admin/cidades` |
| Amenidades | `/admin/amenidades` |
| Analytics | `/admin/analytics` |
| Leads | `/admin/leads` |
| Candidaturas | `/admin/candidaturas` |
| Conf. Contato | `/admin/contato-config` |
---
## 7. Fora de escopo
- Alterar políticas de autorização backend
- Criar novas rotas ou módulos administrativos
- Persistir preferências de navegação em banco
- Transformar a navbar em configuração dinâmica vinda da API

View file

@ -0,0 +1,165 @@
# Data Model — 030-navbar-topo-ux
> Esta feature não cria entidades de banco nem migrations. O modelo abaixo descreve dados de sessão já existentes e o estado de UI necessário para a navbar.
---
## 1. Entidades Existentes Consumidas
### `AuthSession`
Fonte: `frontend/src/contexts/AuthContext.tsx`
| Campo | Tipo | Origem | Uso na navbar |
|---|---|---|---|
| `user` | `User | null` | contexto de autenticação | Decide exibição de avatar, primeiro nome e papel do usuário |
| `token` | `string | null` | `localStorage` + contexto | Não renderiza UI diretamente; sustenta estado autenticado |
| `isAuthenticated` | `boolean` | derivado do contexto | Liga/desliga ações de visitante vs cliente/admin |
| `isLoading` | `boolean` | bootstrap da sessão | Evita flicker de ações incorretas durante hidratação |
| `logout()` | `() => void` | contexto | Encerra sessão e força retorno visual ao estado visitante |
### `UserProfile`
Fonte: tipo `User` retornado pelo fluxo de auth atual.
| Campo | Tipo | Regra | Uso na navbar |
|---|---|---|---|
| `name` | `string` | obrigatório quando `user != null` | Exibe inicial e primeiro nome truncado |
| `role` | `string` | valor relevante nesta feature: `admin` ou não-admin | Controla presença do menu Admin e do menu Minha Conta |
**Invariantes**:
- `user === null` implica navbar de visitante.
- `user.role === 'admin'` implica ausência do menu de cliente padrão.
- Nome longo nunca deve quebrar layout; deve ser truncado no gatilho.
---
## 2. Entidades de Navegação
### `NavItem`
Representa um item navegável exibido em um dos grupos da navbar.
```ts
interface NavItem {
label: string
to: string
end?: boolean
visibility: 'public' | 'client' | 'admin'
group: 'primary' | 'contextual' | 'cta'
}
```
**Regras**:
- Itens `public` aparecem para todos, salvo ajustes de prioridade visual.
- Itens `client` aparecem apenas quando `isAuthenticated === true` e `role !== 'admin'`.
- Itens `admin` aparecem apenas quando `role === 'admin'`.
- `group='cta'` não substitui o grupo público; ele complementa a hierarquia do topo.
### `ProfileSection`
Agrupa ações contextuais por perfil no mobile e no desktop.
```ts
interface ProfileSection {
id: 'admin' | 'client'
title: string
items: NavItem[]
includesLogout: boolean
}
```
**Regras**:
- No mobile, cada seção deve aparecer com cabeçalho próprio.
- Logout deve permanecer visualmente separado das ações de navegação.
---
## 3. Estado Transitório de UI
### `NavUIState`
Modelo recomendado para governar interações mutuamente exclusivas.
```ts
type ActiveOverlay = 'closed' | 'mobile' | 'admin' | 'client'
interface NavUIState {
activeOverlay: ActiveOverlay
isDesktop: boolean
}
```
**Regras de transição**:
| Evento | Estado atual | Próximo estado | Observação |
|---|---|---|---|
| Clique no hambúrguer | `closed` | `mobile` | Só em mobile |
| Clique no hambúrguer | `mobile` | `closed` | Toggle padrão |
| Clique no gatilho Admin | `closed` ou `client` | `admin` | Fecha qualquer outro overlay |
| Clique no gatilho Cliente | `closed` ou `admin` | `client` | Fecha qualquer outro overlay |
| Clique fora | `mobile` / `admin` / `client` | `closed` | Requisito FR-012 |
| Mudança de rota | qualquer | `closed` | Requisito FR-013 |
| Logout confirmado | qualquer | `closed` | Navbar volta ao estado visitante |
| Escape | `mobile` / `admin` / `client` | `closed` | Recomendado para previsibilidade |
**Invariantes**:
- Apenas um contexto pode permanecer aberto por vez.
- `mobile` não pode coexistir com `admin` ou `client`.
- `admin` e `client` são mutuamente exclusivos.
---
## 4. Estados Derivados de Exibição
### `NavbarVariant`
```ts
type NavbarVariant = 'visitor' | 'client' | 'admin'
```
Derivação:
- `visitor`: `!isLoading && !isAuthenticated`
- `client`: `isAuthenticated && user?.role !== 'admin'`
- `admin`: `isAuthenticated && user?.role === 'admin'`
### `ActiveLinkState`
Estado derivado de `NavLink`/rota atual.
**Regras**:
- Links públicos devem refletir estado ativo em desktop e mobile.
- Rotas com query string, como `/imoveis?listing_type=venda`, devem manter coerência visual com a intenção do link.
- Itens contextuais devem fechar o menu após navegação bem-sucedida.
---
## 5. Regras de Validação de UX/A11y
| Regra | Aplicação |
|---|---|
| `aria-expanded` coerente | gatilhos do menu mobile e dropdowns |
| `aria-controls` presente | menu hambúrguer e, se aplicável, painéis contextuais |
| foco visível | todos os links e botões interativos |
| alvo mínimo `44x44` | hambúrguer, CTA, itens tocáveis em mobile |
| truncamento elegante | nome do usuário e gatilhos de perfil |
---
## 6. Relações Entre Entidades
```text
AuthSession
└── UserProfile
├── role ──────────────┐
└── name ───────┐ │
│ │
NavbarVariant <─────────┘ │
NavItem.visibility ────────────┘
NavUIState.activeOverlay
├── controls mobile menu visibility
├── controls admin dropdown visibility
└── controls client dropdown visibility
```

View file

@ -0,0 +1,123 @@
# Implementation Plan: Navbar Topo UX
**Branch**: `030-navbar-topo-ux` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/030-navbar-topo-ux/spec.md`
## Summary
Revisar e refinar a navbar fixa do topo, concentrando a implementação no frontend React para separar melhor navegação pública e contextual por perfil, melhorar a hierarquia visual em desktop/mobile, consolidar o controle de estados transitórios (menu mobile, dropdown admin, dropdown cliente) e elevar acessibilidade sem alterar contratos backend. A principal superfície técnica atual é `frontend/src/components/Navbar.tsx`, apoiada por `AuthContext`, `FavoritesContext`, rotas já existentes no SPA e tokens visuais já definidos em `index.css`.
---
## Technical Context
**Language/Version**: TypeScript 5.5 (frontend principal) / Python 3.12 existente sem mudança funcional
**Primary Dependencies**: React 18, react-router-dom v6, Tailwind CSS 3.4, Axios (indireto via autenticação), contexto próprio `useAuth`, `useFavorites`, `ThemeToggle`
**Storage**: N/A para persistência nova; sessão e token continuam em `localStorage` via `AuthContext`
**Testing**: `npm run build` no frontend + validação manual responsiva/a11y; `pytest` backend não deve ser impactado
**Target Platform**: SPA React em navegadores desktop e mobile, servida por Vite/Nginx no ambiente atual
**Project Type**: aplicação web full-stack com foco nesta feature em SPA frontend/UX
**Performance Goals**: navegação e abertura/fechamento de menus sem jank perceptível; interações do topo percebidas em < 100 ms; zero quebra visual entre 320 px e 1440 px+
**Constraints**: sem alterar autorização backend; preservar rotas existentes de admin e área do cliente; respeitar `DESIGN.md`, tokens do projeto e suporte atual a tema claro/escuro; garantir foco visível, `aria-*` coerente e alvo mínimo de 44x44 px em mobile
**Scale/Scope**: um componente navbar compartilhado entre páginas públicas, área do cliente e área admin; 3 perfis de usuário; 3 contextos interativos mutuamente exclusivos
---
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Princípio | Status | Observação |
|-----------|--------|------------|
| **I. Design-First** | ✅ PASS | A revisão mantém a navbar alinhada ao sistema visual existente, reutilizando tokens e variáveis globais em vez de introduzir paleta ad hoc. O tema claro/escuro já existe no produto e será respeitado sem romper o padrão visual. |
| **II. Separation of Concerns** | ✅ PASS | A feature altera navegação e estado visual no React; não exige renderização no backend nem novo acoplamento entre Flask e SPA. |
| **III. Spec-Driven** | ✅ PASS | O plano deriva diretamente de `spec.md`, com user stories por perfil e critérios de sucesso claros antes da implementação. |
| **IV. Data Integrity** | ✅ PASS | Não há mudança de schema, payload ou persistência. A feature consome apenas dados já existentes de sessão (`user`, `role`, `isAuthenticated`). |
| **V. Security** | ✅ PASS | O menu admin continua condicionado ao papel `admin` no frontend; nenhuma credencial nova será exposta e nenhuma rota protegida será aberta a outros perfis. |
| **VI. Simplicity First** | ✅ PASS | O caminho proposto evita novos pacotes e favorece simplificação do estado atual da navbar, priorizando um único controlador de overlay/menu em vez de abstrações extras. |
**Veredicto Pré-Design**: Sem violações. A feature pode prosseguir para pesquisa e desenho técnico.
**Revalidação Pós-Design**: Mantida como ✅ PASS após a definição do contrato de UI, do modelo de estado e do quickstart. Não surgiram dependências novas nem necessidade de mudança backend.
---
## Project Structure
### Documentação (esta feature)
```text
specs/030-navbar-topo-ux/
├── spec.md # Especificação do produto
├── plan.md # Este arquivo
├── research.md # Decisões e tradeoffs de UX/UI e arquitetura local
├── data-model.md # Modelo de estado e entidades da navbar
├── quickstart.md # Fluxo recomendado de implementação e validação
├── contracts/
│ └── navbar-ui-contract.md # Contrato de comportamento por perfil/breakpoint
└── tasks.md # Phase 2 — gerado por /speckit.tasks
```
### Código-fonte (raiz do repositório)
```text
frontend/
└── src/
├── components/
│ ├── Navbar.tsx # SUPERFÍCIE PRINCIPAL — links, dropdowns e menu mobile
│ └── ThemeToggle.tsx # Reutilizado no topo desktop/mobile
├── contexts/
│ ├── AuthContext.tsx # Fonte de verdade da sessão e logout
│ └── FavoritesContext.tsx # Badge/link de favoritos para visitante
├── layouts/
│ ├── ClientLayout.tsx # Consome Navbar na área do cliente
│ └── AdminLayout.tsx # Consome Navbar na área admin
├── pages/
│ ├── HomePage.tsx
│ ├── PropertiesPage.tsx
│ ├── ContactPage.tsx
│ └── ... # Demais páginas públicas que já renderizam Navbar
├── App.tsx # Rotas já existentes usadas pelo contrato de navegação
└── index.css # Variáveis/tokens e acabamento visual do topo
backend/
└── app/
└── ... # Sem mudanças previstas para esta feature
```
**Structure Decision**: Manter a arquitetura web existente e concentrar a implementação no frontend, com alterações localizadas em `Navbar.tsx`, possíveis ajustes pequenos em `AuthContext.tsx` para fluxo de logout/navegação e refinamentos visuais em `index.css`. Não há necessidade de novos módulos backend ou novos serviços HTTP.
---
## Implementation Approach
### 1. Arquitetura de informação da navbar
- Manter até 5 links públicos primários no desktop, com prioridade para descoberta de imóveis e contato.
- Separar claramente três blocos visuais: marca/logo, navegação pública e ações contextuais de perfil/CTA.
- Tratar o menu Admin como navegação contextual especializada, não como parte da navegação pública.
### 2. Modelo de estado local
- Consolidar o controle de menus para garantir exclusão mútua entre `mobile`, `admin` e `client`.
- Fechar qualquer overlay ao trocar rota, ao clicar fora e ao iniciar logout.
- Derivar a renderização por perfil a partir do estado já disponível em `useAuth()`.
### 3. Acessibilidade e previsibilidade
- Garantir rótulos e estados ARIA coerentes para gatilhos de dropdown e hambúrguer.
- Tornar foco e navegação por teclado parte do contrato da feature, inclusive nos gatilhos contextuais.
- Padronizar feedbacks de hover/active/open com transições curtas e consistentes.
### 4. Escopo de backend
- Nenhuma API nova.
- Nenhuma mudança em autorização.
- Nenhuma migration.
---
## Complexity Tracking
Nenhuma violação constitucional identificada. Não há complexidade excepcional que exija justificativa adicional nesta fase.

View file

@ -0,0 +1,105 @@
# Quickstart — 030-navbar-topo-ux
Guia curto para implementar e validar a revisão UX/UI da navbar do topo.
---
## Pré-requisitos
- Ambiente do projeto iniciado via `./start.ps1` na raiz.
- Frontend disponível em `http://localhost:5174`.
- Branch de trabalho: `030-navbar-topo-ux`.
Credenciais úteis encontradas no frontend atual:
- Admin: `admin@demo.com` / `admin1234`
- Usuário: `usuario@demo.com` / `demo1234`
---
## Superfícies de implementação
Arquivos com maior probabilidade de edição:
- `frontend/src/components/Navbar.tsx`
- `frontend/src/contexts/AuthContext.tsx`
- `frontend/src/index.css`
- Opcionalmente `frontend/src/layouts/ClientLayout.tsx` e `frontend/src/layouts/AdminLayout.tsx` se houver ajuste fino de offset/spacing
---
## Ordem recomendada de trabalho
### 1. Revisar a arquitetura local da navbar
- Mapear os grupos de links públicos, cliente e admin.
- Definir o modelo de estado único para overlays/contextos abertos.
- Garantir fechamento em clique fora, troca de rota e logout.
### 2. Ajustar a hierarquia visual desktop
- Reequilibrar logo, links primários, CTA e ações de conta.
- Limitar ruído visual e reforçar separação entre navegação pública e contextual.
- Validar truncamento do nome e destaque do CTA em ambos os temas.
### 3. Ajustar o comportamento mobile
- Garantir ordem lógica dos destinos no menu hambúrguer.
- Exibir seções `Minha Conta` e `Admin` apenas para os perfis corretos.
- Validar alvo de toque, foco e estados abertos/fechados.
### 4. Refinar logout e previsibilidade
- Conferir se logout fecha menus e retorna imediatamente ao estado visual de visitante.
- Se necessário, ajustar o comportamento atual de redirecionamento em `AuthContext.tsx` para evitar fricção visual desnecessária.
---
## Validação executável
Na pasta `frontend/`:
```bash
npm run build
```
Esse é o guardrail mínimo obrigatório antes de concluir a implementação.
---
## Checklist manual por persona
### Visitante
- Desktop: links principais legíveis, CTA `Anunciar imóvel` destacado e ação `Entrar` visível.
- Mobile: hambúrguer abre e fecha corretamente, com os mesmos destinos públicos em ordem lógica.
- Tema claro/escuro: contraste suficiente em texto, hover e estado ativo.
### Usuário autenticado
- Desktop: avatar/inicial, primeiro nome truncado e dropdown `Minha conta` funcionam.
- Dropdown: `Favoritos`, `Comparar`, `Visitas`, `Minha conta` e `Sair` fecham menu após clique.
- Mobile: seção `Minha Conta` aparece apenas quando autenticado não-admin.
### Admin
- Desktop: gatilho `Admin` visível e separado da navegação pública.
- Dropdown: atalhos admin navegam corretamente e fecham o menu.
- Mobile: seção `Admin` aparece e não conflita com outros contextos.
---
## Checklist de comportamento global
- Abrir dropdown admin fecha dropdown cliente.
- Abrir dropdown cliente fecha dropdown admin.
- Trocar de rota fecha menu mobile e dropdowns.
- Clique fora fecha o contexto aberto.
- `Tab`, `Enter` e `Espaço` funcionam nos gatilhos relevantes.
- Em 320 px, 768 px, 1024 px e 1280 px não há quebra ou sobreposição.
---
## Risco conhecido para implementação
O comportamento atual de logout em `AuthContext.tsx` usa redirecionamento forçado para `/login`. Isso é funcional, mas pode entrar em tensão com o objetivo da spec de “retornar à navbar de visitante” com mínima fricção visual. A implementação deve decidir se mantém esse fluxo por regra de produto ou se o suaviza no frontend sem alterar segurança.

View file

@ -0,0 +1,71 @@
# Research — 030-navbar-topo-ux
## Decision 1: Manter uma única Navbar compartilhada por todo o produto
**Decision**: Evoluir o componente compartilhado `frontend/src/components/Navbar.tsx` em vez de criar navbars separadas para visitante, cliente e admin.
**Rationale**: A navbar já é consumida por páginas públicas e layouts protegidos. Um único ponto de evolução reduz divergência visual, evita duplicação de links/rotas e facilita garantir consistência de comportamento entre desktop e mobile.
**Alternatives considered**:
- Criar três variantes independentes de navbar: rejeitado por elevar custo de manutenção e risco de regressões cruzadas.
- Extrair uma navbar diferente para admin: rejeitado nesta fase porque a spec pede eficiência sem poluir a experiência global, não uma shell totalmente nova.
---
## Decision 2: Consolidar o estado transitório em um único controlador de overlay
**Decision**: Planejar a navbar com um estado mutuamente exclusivo para `closed`, `mobile`, `client` e `admin`, mesmo que a implementação inicial parta do componente atual com múltiplos booleans.
**Rationale**: Os requisitos FR-011, FR-012 e FR-013 pedem previsibilidade forte. Um controlador único reduz a chance de estados simultâneos, simplifica o fechamento em mudança de rota e deixa o comportamento mais testável.
**Alternatives considered**:
- Manter três `useState` independentes: rejeitado como forma final porque exige disciplina manual em todos os handlers.
- Levar o estado para contexto global: rejeitado por excesso de complexidade para uma concern local de UI.
---
## Decision 3: Não introduzir novos contratos de API nem mudanças backend
**Decision**: Tratar a feature como frontend/UX puro, apoiada apenas pelos dados já disponíveis em `AuthContext` e nas rotas existentes em `App.tsx`.
**Rationale**: A spec descreve comportamento e hierarquia visual, não novos fluxos de domínio. `user`, `role`, `isAuthenticated` e `logout()` já cobrem as condições necessárias para as três personas.
**Alternatives considered**:
- Criar endpoint específico para navegação por perfil: rejeitado por desnecessário e contrário ao princípio de simplicidade.
- Mover a configuração de menu para backend: rejeitado nesta fase por não haver requisito de CMS/configuração dinâmica.
---
## Decision 4: Derivar destinos do menu a partir das rotas reais existentes
**Decision**: Basear o contrato de navegação nos destinos já disponíveis em `frontend/src/App.tsx` e na lista atual de módulos administrativos/cliente.
**Rationale**: Isso evita planejar links inexistentes e mantém a feature ancorada no sistema real. Também permite priorizar atalhos admin sem inventar módulos novos.
**Alternatives considered**:
- Redesenhar a informação da navbar a partir de destinos hipotéticos: rejeitado por abrir escopo além da spec.
- Remover rotas admin do topo nesta fase: rejeitado porque a spec exige acesso rápido para administradores.
---
## Decision 5: Formalizar um contrato de UI da navbar
**Decision**: Criar um documento em `contracts/navbar-ui-contract.md` descrevendo comportamento por perfil, breakpoint e estado interativo.
**Rationale**: Embora não exista API nova, a aplicação expõe uma interface de navegação para o usuário final. O contrato de UI serve como referência objetiva para implementação, QA e futura geração de tarefas.
**Alternatives considered**:
- Não criar contrato algum: rejeitado porque a feature é centrada em interação e estados, e isso precisa de definição explícita.
- Modelar o contrato só dentro do plan: rejeitado para manter separação clara entre abordagem técnica e comportamento esperado.
---
## Decision 6: Validar principalmente com build e checklist manual responsivo
**Decision**: Adotar `npm run build` como validação executável mínima e complementar com checklist manual por persona e breakpoint.
**Rationale**: O frontend atual não expõe uma suíte automatizada de testes de componentes/RTL. A natureza visual/interativa da navbar exige inspeção manual em desktop/mobile além da checagem de compilação.
**Alternatives considered**:
- Introduzir Vitest/RTL apenas para esta feature: rejeitado nesta fase de planning por ampliar escopo e dependências.
- Confiar só em validação visual manual: rejeitado porque o build ainda é o guardrail executável mínimo disponível.

View file

@ -0,0 +1,195 @@
# Feature Specification: Revisão UX/UI da Navbar do Topo
**Feature Branch**: `030-navbar-topo-ux`
**Created**: 2026-04-22
**Status**: Draft
---
## Objetivo
Melhorar a navbar fixa do topo para oferecer uma navegação mais clara, consistente e eficiente em desktop e mobile, com comportamento específico por perfil:
- Visitante (não autenticado)
- Usuário autenticado (cliente)
- Usuário admin
A feature deve equilibrar descoberta de funcionalidades, redução de ruído visual e eficiência de navegação para tarefas críticas.
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Navegação principal clara para visitantes (Priority: P1)
Como visitante, quero entender rapidamente os caminhos principais do site para encontrar imóveis, equipe e contato sem confusão.
**Why this priority**: A navbar é o principal ponto de orientação global da aplicação e impacta diretamente descoberta, conversão e retenção.
**Independent Test**: Em páginas públicas, o visitante visualiza links principais, CTA e autenticação com hierarquia visual clara.
**Acceptance Scenarios**:
1. **Given** um visitante em viewport desktop, **When** a navbar é exibida, **Then** os itens principais de navegação estão visíveis e legíveis, sem truncamento.
2. **Given** um visitante, **When** clica em um item principal (ex: Comprar, Alugar, Contato), **Then** é redirecionado ao destino correto e o estado ativo é refletido quando aplicável.
3. **Given** um visitante, **When** interage com ações de topo, **Then** o CTA “Anunciar imóvel” e a ação “Entrar” aparecem com destaque adequado e consistente.
4. **Given** um visitante em mobile, **When** abre o menu hambúrguer, **Then** encontra os mesmos destinos principais em ordem lógica e com alvo de toque adequado.
---
### User Story 2 — Menu de usuário autenticado orientado a tarefas (Priority: P1)
Como usuário autenticado, quero acessar rapidamente minhas ações pessoais pela navbar para continuar minha jornada sem fricção.
**Why this priority**: Usuário logado tem intenção clara; reduzir cliques e ambiguidade melhora eficiência e percepção de produto.
**Independent Test**: Ao autenticar como cliente, o menu do usuário é exibido com ações pessoais e fluxo de logout confiável.
**Acceptance Scenarios**:
1. **Given** um cliente autenticado (não admin), **When** visualiza a navbar desktop, **Then** vê avatar/inicial, primeiro nome e um dropdown de conta.
2. **Given** o dropdown do usuário aberto, **When** o cliente seleciona uma opção, **Then** navega para a rota correspondente e o dropdown fecha.
3. **Given** o cliente autenticado, **When** escolhe “Sair”, **Then** a sessão é encerrada e a navbar retorna ao estado de visitante.
4. **Given** o cliente autenticado em mobile, **When** abre o menu, **Then** encontra uma seção “Minha Conta” contendo links pessoais e logout.
---
### User Story 3 — Menu admin com acesso rápido e controle visual (Priority: P1)
Como administrador, quero acessar módulos administrativos pela navbar sem poluir a experiência dos demais usuários.
**Why this priority**: Admin precisa de eficiência operacional, mas o sistema também deve preservar clareza para perfis não-admin.
**Independent Test**: Ao autenticar como admin, o menu Admin aparece; para outros perfis ele não aparece.
**Acceptance Scenarios**:
1. **Given** um usuário com role admin, **When** acessa o sistema, **Then** o gatilho do dropdown “Admin” é exibido na navbar desktop.
2. **Given** o dropdown “Admin” aberto, **When** o admin escolhe um módulo, **Then** navega para o destino correto e o menu fecha.
3. **Given** um usuário não admin, **When** navega no sistema, **Then** não vê o menu Admin em nenhum breakpoint.
4. **Given** admin em mobile, **When** abre o menu, **Then** encontra uma seção “Admin” com os mesmos módulos prioritários da navegação desktop.
---
### User Story 4 — Navbar previsível, acessível e sem conflitos de estado (Priority: P2)
Como usuário, quero que os menus da navbar respondam de forma previsível para não perder contexto durante a navegação.
**Why this priority**: A navbar concentra múltiplos estados (menu mobile, dropdown usuário, dropdown admin), aumentando risco de comportamento inconsistente.
**Independent Test**: Abrir/fechar menus mantém estados consistentes com clique externo, teclado e mudança de rota.
**Acceptance Scenarios**:
1. **Given** dropdown admin aberto, **When** o usuário abre dropdown do cliente, **Then** o dropdown admin fecha automaticamente.
2. **Given** qualquer dropdown aberto, **When** ocorre clique fora, **Then** o dropdown fecha.
3. **Given** menu mobile aberto, **When** o usuário navega para outra rota, **Then** o menu fecha automaticamente.
4. **Given** navegação por teclado, **When** foco percorre a navbar, **Then** botões e links têm foco visível e acionamento por Enter/Espaço quando aplicável.
---
### User Story 5 — Hierarquia visual e responsividade premium (Priority: P2)
Como usuário em desktop e mobile, quero uma navbar com equilíbrio visual e legibilidade para navegar com confiança em qualquer tamanho de tela.
**Why this priority**: Qualidade visual e consistência de comportamento influenciam diretamente confiança e percepção de profissionalismo.
**Independent Test**: Em breakpoints principais, navbar mantém legibilidade, contraste e espaçamento sem sobreposição de elementos.
**Acceptance Scenarios**:
1. **Given** telas entre 320px e 1440px+, **When** a navbar é renderizada, **Then** não há quebra de layout ou sobreposição de elementos.
2. **Given** conteúdo de nome longo do usuário, **When** exibido no gatilho da conta, **Then** é truncado de forma elegante sem quebrar alinhamento.
3. **Given** tema claro/escuro, **When** a navbar é exibida, **Then** contraste de texto e estados hover/active permanecem acessíveis.
4. **Given** navegação prolongada, **When** o usuário rola e interage repetidamente, **Then** a navbar fixa mantém performance fluida sem jank perceptível.
---
## Edge Cases
- Sessão expira com dropdown aberto: navbar deve invalidar estado autenticado e retornar ao estado visitante sem erro visual.
- Usuário admin com nome muito longo: gatilho de conta/admin não deve deslocar links primários fora da área visível.
- Rotas com query string (ex: listagem filtrada): estado ativo da navegação deve permanecer coerente.
- Abertura simultânea de menu mobile e dropdown contextual: apenas um contexto de navegação deve permanecer aberto por vez.
- Falha ao executar logout: deve mostrar feedback de erro e manter usuário autenticado até confirmação.
---
## Requirements *(mandatory)*
### Functional Requirements
**Arquitetura da navegação**
- **FR-001**: A navbar DEVE manter uma área de navegação principal comum para todos os perfis (links públicos).
- **FR-002**: O sistema DEVE exibir estados de navbar distintos para visitante, cliente autenticado e admin autenticado.
- **FR-003**: O menu Admin DEVE ser exibido somente para usuários com `role=admin`.
- **FR-004**: O menu do usuário (cliente) DEVE ser exibido somente para usuários autenticados não-admin.
**Menu do usuário (cliente)**
- **FR-005**: O dropdown do cliente DEVE incluir entradas para `Favoritos`, `Comparar`, `Visitas` e `Minha conta`.
- **FR-006**: O dropdown do cliente DEVE incluir ação de `Sair` separada visualmente das ações de navegação.
- **FR-007**: Em mobile, as ações do cliente DEVEM aparecer agrupadas sob uma seção `Minha Conta` dentro do menu principal.
**Menu Admin**
- **FR-008**: O dropdown Admin DEVE incluir atalhos para os módulos administrativos vigentes no sistema.
- **FR-009**: Em mobile, os atalhos admin DEVEM ser exibidos em seção dedicada `Admin`.
- **FR-010**: O menu Admin NÃO DEVE estar presente para visitantes nem clientes não-admin.
**Comportamento de estados**
- **FR-011**: Abrir um dropdown (Admin ou Cliente) DEVE fechar automaticamente o outro dropdown.
- **FR-012**: Clique fora DEVE fechar dropdowns abertos.
- **FR-013**: Mudança de rota DEVE fechar menu mobile e dropdowns abertos.
- **FR-014**: O botão hambúrguer DEVE expor `aria-expanded` e `aria-controls` coerentes com o estado atual.
**Acessibilidade e usabilidade**
- **FR-015**: Todos os gatilhos interativos da navbar DEVEM possuir rótulos acessíveis e foco visível.
- **FR-016**: Alvos de toque em mobile DEVEM respeitar área mínima de interação (44x44 CSS px).
- **FR-017**: O estado ativo dos links DEVE ser visualmente distinguível em desktop e mobile.
**Hierarquia visual e consistência**
- **FR-018**: A navbar DEVE apresentar hierarquia clara entre links primários, menus contextuais (Admin/Conta) e CTAs.
- **FR-019**: O CTA principal DEVE manter contraste e legibilidade adequados em tema claro e escuro.
- **FR-020**: Nomes longos de usuário DEVEM ser truncados sem quebrar layout.
### UX/UI Recommendations (Design Direction)
- **UX-001**: Reduzir densidade cognitiva no topo priorizando no máximo 5 links primários visíveis no desktop.
- **UX-002**: Reforçar separação visual entre navegação pública e navegação contextual de perfil (Admin/Conta).
- **UX-003**: Priorizar consistência de iconografia entre desktop e mobile para ações de conta e logout.
- **UX-004**: Padronizar microinterações (hover, active, open/close) com durações curtas e previsíveis.
- **UX-005**: Incluir indicadores sutis de contexto de perfil (ex: badge/label admin) sem competir com CTA principal.
### Key Entities
- **AuthSession**: Estado de autenticação consumido pela navbar (`isAuthenticated`, `isLoading`, `user`).
- **UserProfile**: Dados mínimos para renderização contextual (`name`, `role`).
- **NavItem**: Item de navegação com destino e rótulo para links públicos, cliente e admin.
- **NavUIState**: Estado transitório da navbar (`menuOpen`, `adminOpen`, `clientOpen`).
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% dos cenários de visibilidade por perfil (visitante, cliente, admin) são respeitados em desktop e mobile.
- **SC-002**: 100% dos links de menu contextual (cliente/admin) navegam para os destinos corretos e fecham o menu após clique.
- **SC-003**: 0 ocorrências de sobreposição/quebra da navbar nos breakpoints suportados (320px, 768px, 1024px, 1280px).
- **SC-004**: Interações básicas de acessibilidade (foco visível, rótulos ARIA essenciais, navegação por teclado) funcionam sem bloqueios.
- **SC-005**: Logout retorna estado visual de visitante em até 1 interação, sem necessidade de refresh manual.
---
## Assumptions
- O fluxo de autenticação atual e o contexto de usuário (`useAuth`) permanecerão como fonte única de verdade para perfil e sessão.
- Os módulos administrativos já existentes continuam válidos como destinos no menu Admin.
- A navegação da Área do Cliente continuará disponível por links no menu do usuário autenticado.
- Esta feature não altera regras de autorização backend; foca em UX/UI e comportamento de navegação no frontend.
- Ajustes de conteúdo textual fino (copywriting) podem ser refinados durante implementação, sem alterar requisitos funcionais.

View file

@ -0,0 +1,261 @@
# Tasks: Navbar Topo UX (030)
**Input**: Design documents de `specs/030-navbar-topo-ux/`
**Prerequisites**: plan.md ✅ · spec.md ✅ · research.md ✅ · data-model.md ✅ · quickstart.md ✅ · contracts/navbar-ui-contract.md ✅
**Branch**: `030-navbar-topo-ux`
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: pode ser executada em paralelo quando tocar arquivos distintos e sem dependência de tarefa incompleta
- **[Story]**: user story correspondente (`US1`, `US2`, `US3`, `US4`, `US5`)
- Caminhos exatos incluídos em cada tarefa
**Tests**: a spec não pede suíte automatizada nova para esta feature. O guardrail executável mínimo é `npm run build` em `frontend/`, complementado pelo checklist manual de `quickstart.md`.
---
## Phase 1: Setup — Preparação da Navbar Compartilhada
**Purpose**: estabelecer a base visual e estrutural da navbar compartilhada antes da refatoração de estados e das variações por perfil.
- [X] T001 Consolidar a configuração de navegação compartilhada em `frontend/src/components/Navbar.tsx` com arrays tipados para links públicos, ações do cliente e atalhos admin, alinhados aos destinos definidos em `specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md`
- [X] T002 [P] Preparar tokens utilitários da navbar fixa em `frontend/src/index.css` para backdrop, foco visível, estados hover/active/open, alvo mínimo de toque e contraste consistente em tema claro/escuro
**Checkpoint**: a base de navegação e os tokens visuais da navbar estão definidos sem introduzir novas dependências externas.
---
## Phase 2: Foundational — Estado e Shell Compartilhados
**Purpose**: implementar a infraestrutura de estado e shell que bloqueia todas as user stories da navbar.
**⚠️ CRÍTICO**: nenhuma user story deve avançar antes desta fase estar concluída.
- [X] T003 Implementar em `frontend/src/components/Navbar.tsx` a derivação explícita de variante `visitor` / `client` / `admin` a partir de `useAuth()` e centralizar a decisão de visibilidade dos blocos públicos, contextuais e de autenticação
- [X] T004 Implementar em `frontend/src/components/Navbar.tsx` um controlador único de overlay com os estados `closed`, `mobile`, `client` e `admin`, substituindo booleans soltos por handlers de abertura/fechamento mutuamente exclusivos
- [X] T005 [P] Integrar em `frontend/src/components/Navbar.tsx` e `frontend/src/contexts/AuthContext.tsx` o fechamento global por mudança de rota, clique fora e logout, garantindo retorno imediato ao estado visual de visitante sem menus órfãos
- [X] T006 [P] Ajustar `frontend/src/layouts/ClientLayout.tsx` e `frontend/src/layouts/AdminLayout.tsx` para respeitar a altura e o empilhamento da navbar fixa sem sobrepor o conteúdo principal após a refatoração
**Checkpoint**: existe uma única fonte de verdade para os overlays da navbar, e os shells compartilham offset compatível com a barra fixa.
---
## Phase 3: User Story 1 — Navegação Principal Clara para Visitantes (Priority: P1) 🎯 MVP
**Goal**: entregar uma navbar pública clara, legível e consistente para visitantes em desktop e mobile.
**Independent Test**: em páginas públicas, o visitante vê até 5 links principais com estado ativo coerente, CTA `Anunciar imóvel`, ação `Entrar` e menu mobile com os mesmos destinos em ordem lógica.
- [X] T007 [US1] Reorganizar a estrutura desktop de visitante em `frontend/src/components/Navbar.tsx` para exibir logo, navegação pública principal, favoritos públicos quando aplicável, CTA `Anunciar imóvel` e ação `Entrar` com hierarquia visual clara
- [X] T008 [P] [US1] Implementar em `frontend/src/components/Navbar.tsx` a lógica de estado ativo para links públicos, incluindo correspondência coerente para rotas com query string como `/imoveis?listing_type=venda` e `/imoveis?listing_type=aluguel`
- [X] T009 [US1] Implementar o menu mobile de visitante em `frontend/src/components/Navbar.tsx` com gatilho hambúrguer, mesma ordem de destinos públicos do desktop, CTA destacado e fechamento automático ao navegar
- [X] T010 [P] [US1] Refinar em `frontend/src/index.css` a aparência da navegação pública desktop/mobile, do CTA principal e dos estados hover/active para manter legibilidade entre 320 px e 1440 px+
**Checkpoint**: US1 fica utilizável de forma independente para visitante em desktop e mobile, com destinos públicos claros e sem truncamento indevido.
---
## Phase 4: User Story 2 — Menu de Usuário Autenticado Orientado a Tarefas (Priority: P1)
**Goal**: permitir que o cliente autenticado acesse rapidamente ações pessoais e logout pela navbar.
**Independent Test**: ao autenticar como cliente não-admin, a navbar exibe gatilho de conta com nome truncado, dropdown com `Favoritos`, `Comparar`, `Visitas`, `Minha conta` e `Sair`, além da seção `Minha Conta` no mobile.
- [X] T011 [US2] Implementar em `frontend/src/components/Navbar.tsx` o gatilho de conta do cliente com inicial/avatar, primeiro nome truncado e dropdown desktop contendo `Favoritos`, `Comparar`, `Visitas`, `Minha conta` e `Sair` com separação visual do logout
- [X] T012 [P] [US2] Ajustar em `frontend/src/contexts/AuthContext.tsx` a superfície consumida pela navbar para suportar renderização confiável de nome, role e logout sem flicker durante hidratação ou encerramento de sessão
- [X] T013 [US2] Integrar em `frontend/src/components/Navbar.tsx` os links de `Favoritos` e `Comparar` com os contextos existentes `frontend/src/contexts/FavoritesContext.tsx` e `frontend/src/contexts/ComparisonContext.tsx` sem quebrar a navegação contextual do cliente
- [X] T014 [US2] Implementar em `frontend/src/components/Navbar.tsx` a seção mobile `Minha Conta` com os mesmos destinos do dropdown desktop, logout separado visualmente e fechamento automático após clique em qualquer ação
**Checkpoint**: US2 fica testável de forma independente com login de cliente, incluindo fluxo confiável de logout e paridade desktop/mobile.
---
## Phase 5: User Story 3 — Menu Admin com Acesso Rápido e Controle Visual (Priority: P1)
**Goal**: expor atalhos administrativos apenas para admins, sem poluir a experiência de visitantes e clientes.
**Independent Test**: ao autenticar como admin, a navbar exibe gatilho `Admin` com atalhos prioritários no desktop e seção `Admin` no mobile; para não-admin, nada disso aparece.
- [X] T015 [US3] Implementar em `frontend/src/components/Navbar.tsx` o gatilho e dropdown desktop `Admin`, exibindo apenas para `role === 'admin'` e listando os módulos existentes definidos em `specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md`
- [X] T016 [US3] Implementar em `frontend/src/components/Navbar.tsx` a seção mobile `Admin` com os mesmos atalhos prioritários do desktop, mantendo exclusão do menu padrão `Minha Conta` para admins
- [X] T017 [P] [US3] Validar e ajustar em `frontend/src/App.tsx` os destinos usados pela navbar admin para garantir que todos os atalhos planejados apontem para rotas já existentes no SPA sem criar rotas novas fora do escopo
**Checkpoint**: US3 fica utilizável por admin sem regressão de visibilidade para visitante ou cliente autenticado.
---
## Phase 6: User Story 4 — Navbar Previsível, Acessível e Sem Conflitos de Estado (Priority: P2)
**Goal**: garantir previsibilidade de interação, acessibilidade básica e exclusão mútua robusta entre menu mobile, dropdown do cliente e dropdown admin.
**Independent Test**: abrir qualquer contexto e verificar fechamento por clique fora, Escape, mudança de rota e abertura de outro contexto; foco, labels e ARIA permanecem coerentes durante navegação por teclado.
- [X] T018 [US4] Aplicar em `frontend/src/components/Navbar.tsx` `aria-label`, `aria-expanded`, `aria-controls`, ids estáveis e suporte a `Enter`, `Espaço` e `Escape` para hambúrguer, dropdown do cliente e dropdown admin
- [X] T019 [P] [US4] Garantir em `frontend/src/index.css` foco visível, contraste acessível e alvo mínimo de 44x44 px para todos os gatilhos e itens clicáveis da navbar em desktop e mobile
- [X] T020 [US4] Consolidar em `frontend/src/components/Navbar.tsx` o fechamento mútuo entre `mobile`, `client` e `admin`, incluindo transições corretas ao trocar rota, clicar fora e executar logout com sucesso ou erro
**Checkpoint**: US4 fica validável com teclado e clique externo, sem estados simultâneos nem overlays presos.
---
## Phase 7: User Story 5 — Hierarquia Visual e Responsividade Premium (Priority: P2)
**Goal**: elevar a qualidade visual e a responsividade da navbar compartilhada em todos os breakpoints suportados.
**Independent Test**: a navbar permanece legível, sem sobreposição e com truncamento elegante de nomes longos em 320 px, 768 px, 1024 px e 1280 px+, nos dois temas.
- [X] T021 [US5] Reequilibrar em `frontend/src/components/Navbar.tsx` a distribuição entre logo, links públicos, ações contextuais, `ThemeToggle` e CTA para evitar sobreposição e excesso de densidade visual nos breakpoints principais
- [X] T022 [P] [US5] Ajustar em `frontend/src/index.css` truncamento elegante de nomes longos, espaçamento responsivo, microinterações curtas e separação visual entre navegação pública e contextual nos temas claro e escuro
- [X] T023 [US5] Revisar em `frontend/src/layouts/ClientLayout.tsx` e `frontend/src/layouts/AdminLayout.tsx` o comportamento do conteúdo após scroll para manter a navbar fixa estável e sem jank perceptível nas áreas autenticadas
**Checkpoint**: US5 fica estável visualmente nos breakpoints e temas suportados, com densidade e truncamento sob controle.
---
## Phase Final: Polish & Validação
**Purpose**: executar o guardrail mínimo e o checklist manual completo da feature antes do merge.
- [X] T024 Executar `npm run build` no diretório `frontend/` e corrigir qualquer erro de TypeScript ou compilação relacionado à navbar compartilhada
- [X] T025 [P] Executar os cenários de validação por persona, tema e breakpoint descritos em `specs/030-navbar-topo-ux/quickstart.md`, cobrindo visitante, cliente, admin e os breakpoints 320 px, 768 px, 1024 px e 1280 px
- [X] T026 [P] Fazer limpeza final em `frontend/src/components/Navbar.tsx` e `frontend/src/index.css`, removendo handlers, classes e estados legados substituídos pela refatoração de overlay único
---
## Dependencies & Execution Order
### Phase Dependencies
```text
Phase 1 (Setup)
└──→ Phase 2 (Foundational)
├──→ Phase 3 (US1 — Visitante)
├──→ Phase 4 (US2 — Cliente)
├──→ Phase 5 (US3 — Admin)
├──→ Phase 6 (US4 — Acessibilidade e estado)
└──→ Phase 7 (US5 — Responsividade premium)
└──→ Phase Final (Build + validação manual)
```
- **Phase 1**: sem dependências; prepara a configuração e os tokens visuais da navbar
- **Phase 2**: depende da conclusão de Phase 1; bloqueia todas as user stories
- **Phase 3 a Phase 7**: dependem de Phase 2; podem avançar em paralelo apenas quando não houver conflito de arquivo
- **Phase Final**: depende das user stories desejadas concluídas
### User Story Dependencies
- **US1 (P1)**: pode começar após Phase 2; independente de US2 e US3 no comportamento de perfil
- **US2 (P1)**: depende de Phase 2 e compartilha `Navbar.tsx` com US1, então a execução prática tende a ser sequencial no mesmo componente
- **US3 (P1)**: depende de Phase 2; pode ocorrer em paralelo apenas com T017, que toca `App.tsx`
- **US4 (P2)**: depende de T004 e T005 porque a base de overlay único e fechamento global precisa existir primeiro
- **US5 (P2)**: depende da estrutura renderizada por US1, US2 e US3 para calibrar responsividade final com dados reais
### Task-Level Notes
- T002 pode rodar em paralelo com T001
- T005 e T006 podem rodar em paralelo após T003 e T004
- T008 e T010 podem rodar em paralelo dentro de US1
- T012 pode rodar em paralelo com T011; T013 pode iniciar após T011
- T017 pode rodar em paralelo com T015 e T016
- T019 pode rodar em paralelo com T018; T020 depende da instrumentação criada em T018
- T022 pode rodar em paralelo com T021; T023 depende do ajuste de altura/layout consolidado
---
## Parallel Execution Examples
### User Story 1
```text
T007 → T009
T008 || T010
```
### User Story 2
```text
T011 → T013 → T014
T012 pode ocorrer em paralelo a T011
```
### User Story 3
```text
T015 → T016
T017 pode ocorrer em paralelo a T015
```
### User Story 4
```text
T018 → T020
T019 pode ocorrer em paralelo a T018
```
### User Story 5
```text
T021 → T023
T022 pode ocorrer em paralelo a T021
```
---
## Implementation Strategy
### MVP First
1. Concluir Phase 1 e Phase 2
2. Entregar US1 para visitante como primeiro incremento navegável
3. Adicionar US2 e US3 para fechar a matriz de perfis da navbar
4. Validar build e checklist manual antes de partir para polish visual fino
### Incremental Delivery
1. **Incremento 1**: Setup + Foundational
2. **Incremento 2**: US1 — navegação pública clara em desktop/mobile
3. **Incremento 3**: US2 + US3 — menus contextuais por perfil autenticado
4. **Incremento 4**: US4 — previsibilidade, teclado e ARIA
5. **Incremento 5**: US5 — acabamento responsivo premium
### Suggested MVP Scope
O menor recorte demonstrável é **US1** após a fase Foundational. O menor recorte funcional completo para a matriz de perfis é **US1 + US2 + US3**.
---
## Summary
| Fase | Tarefas | Escopo |
|------|---------|--------|
| Phase 1 | T001T002 | Setup da navbar compartilhada |
| Phase 2 | T003T006 | Estado e shell bloqueadores |
| Phase 3 | T007T010 | US1 — visitante |
| Phase 4 | T011T014 | US2 — cliente autenticado |
| Phase 5 | T015T017 | US3 — admin |
| Phase 6 | T018T020 | US4 — acessibilidade e previsibilidade |
| Phase 7 | T021T023 | US5 — responsividade premium |
| Phase Final | T024T026 | Build, validação manual e limpeza |
| **Total** | **26 tarefas** | **5 user stories + setup/foundational/polish** |

View file

@ -0,0 +1,28 @@
# Implementation Plan: Home Hero Light/Dark
**Branch**: `031-home-hero-light-dark` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
## Summary
Adicionar suporte de imagem hero por tema (light/dark) na configuração da home, com CRUD administrativo focado em edição de URLs e fallback para o campo legado.
## Technical Context
- Backend: Flask 3.x, SQLAlchemy, Alembic, Pydantic v2
- Frontend: React 18, TypeScript 5.5, Tailwind
- Persistência: tabela `homepage_config` (novas colunas)
## Scope
1. Migration para adicionar colunas `hero_image_light_url` e `hero_image_dark_url`.
2. Atualização de model/schemas/endpoint público de homepage.
3. Novo endpoint admin para atualizar homepage config.
4. Nova página admin para edição da configuração da home.
5. Atualizar navbar/admin routes para acesso à página.
6. Ajustar HomePage para escolher imagem por tema.
7. Atualizar seed padrão com valores light/dark.
## Validation
- `npm run build` em `frontend/`
- checagem de erros nos arquivos backend alterados

Some files were not shown because too many files have changed in this diff Show more