feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint
migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"feature_directory": "specs/023-ux-melhorias-imoveis"
|
||||
"feature_directory": "specs/029-ux-area-do-cliente"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
from app.models import page_view as _page_view_models # noqa: F401
|
||||
from app.models import agent as _agent_models # noqa: F401
|
||||
from app.models import imobiliaria as _imobiliaria_models # noqa: F401
|
||||
from app.models import contact_config as _contact_config_models # noqa: F401
|
||||
from app.models import job_application as _job_application_models # noqa: F401
|
||||
|
||||
# JWT secret key — raises KeyError if not set (fail-fast on misconfiguration)
|
||||
app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]
|
||||
|
|
@ -54,6 +56,8 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
from app.routes.config import config_bp
|
||||
from app.routes.agents import agents_public_bp, agents_admin_bp
|
||||
from app.routes.version import version_bp
|
||||
from app.routes.contact_config import contact_config_bp
|
||||
from app.routes.jobs import jobs_public_bp, jobs_admin_bp
|
||||
|
||||
app.register_blueprint(homepage_bp)
|
||||
app.register_blueprint(properties_bp)
|
||||
|
|
@ -67,10 +71,14 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||
app.register_blueprint(agents_public_bp)
|
||||
app.register_blueprint(agents_admin_bp)
|
||||
app.register_blueprint(version_bp)
|
||||
app.register_blueprint(contact_config_bp)
|
||||
app.register_blueprint(jobs_public_bp)
|
||||
app.register_blueprint(jobs_admin_bp)
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
from flask import jsonify
|
||||
|
||||
try:
|
||||
db.session.execute(db.text("SELECT 1"))
|
||||
return jsonify({"status": "ok", "db": "ok"}), 200
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ from .saved_property import SavedProperty
|
|||
from .visit_request import VisitRequest
|
||||
from .boleto import Boleto
|
||||
from .page_view import PageView
|
||||
from .contact_config import ContactConfig
|
||||
|
|
|
|||
22
backend/app/models/contact_config.py
Normal file
22
backend/app/models/contact_config.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from app.extensions import db
|
||||
|
||||
|
||||
class ContactConfig(db.Model):
|
||||
__tablename__ = "contact_config"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
address_street = db.Column(db.String(200), nullable=True)
|
||||
address_neighborhood_city = db.Column(db.String(200), nullable=True)
|
||||
address_zip = db.Column(db.String(20), nullable=True)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
email = db.Column(db.String(254), nullable=True)
|
||||
business_hours = db.Column(db.Text, nullable=True)
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
onupdate=db.func.now(),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactConfig id={self.id}>"
|
||||
|
|
@ -11,6 +11,8 @@ class HomepageConfig(db.Model):
|
|||
hero_cta_url = db.Column(db.String(200), nullable=False, default="/imoveis")
|
||||
featured_properties_limit = db.Column(db.Integer, nullable=False, default=6)
|
||||
hero_image_url = db.Column(db.String(512), nullable=True)
|
||||
hero_image_light_url = db.Column(db.String(512), nullable=True)
|
||||
hero_image_dark_url = db.Column(db.String(512), nullable=True)
|
||||
updated_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
|
|
|
|||
27
backend/app/models/job_application.py
Normal file
27
backend/app/models/job_application.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from app.extensions import db
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplication(db.Model):
|
||||
__tablename__ = "job_applications"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(150), nullable=False)
|
||||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(30), nullable=True)
|
||||
role_interest = db.Column(db.String(100), nullable=False)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
file_name = db.Column(db.String(255), nullable=True)
|
||||
status = db.Column(db.String(50), nullable=False, default="pending")
|
||||
created_at = db.Column(
|
||||
db.DateTime, nullable=False, server_default=db.func.now()
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<JobApplication id={self.id} email={self.email!r}>"
|
||||
|
|
@ -15,6 +15,10 @@ class ContactLead(db.Model):
|
|||
email = db.Column(db.String(254), nullable=False)
|
||||
phone = db.Column(db.String(20), nullable=True)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
source = db.Column(
|
||||
db.String(100), nullable=True
|
||||
) # contato | imovel | cadastro_residencia
|
||||
source_detail = db.Column(db.String(255), nullable=True) # ex: título do imóvel
|
||||
created_at = db.Column(
|
||||
db.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
|
|
@ -22,4 +26,4 @@ class ContactLead(db.Model):
|
|||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactLead id={self.id} email={self.email!r}>"
|
||||
return f"<ContactLead id={self.id} email={self.email!r} source={self.source!r}>"
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ class PhotoAdminOut(BaseModel):
|
|||
|
||||
class PropertyAdminOut(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
title: str
|
||||
code: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
|
|
@ -172,6 +173,7 @@ class PropertyAdminOut(BaseModel):
|
|||
def from_prop(cls, p: Property) -> "PropertyAdminOut":
|
||||
return cls(
|
||||
id=str(p.id),
|
||||
slug=p.slug,
|
||||
title=p.title,
|
||||
code=p.code,
|
||||
address=p.address,
|
||||
|
|
@ -213,6 +215,8 @@ def admin_list_properties():
|
|||
q = request.args.get("q", "").strip()
|
||||
city_id = request.args.get("city_id", type=int)
|
||||
neighborhood_id = request.args.get("neighborhood_id", type=int)
|
||||
type_filter = request.args.get("type") # 'venda' | 'aluguel' | None
|
||||
is_active_raw = request.args.get("is_active") # 'true' | 'false' | None
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(50, max(1, int(request.args.get("per_page", 12))))
|
||||
|
|
@ -231,6 +235,12 @@ def admin_list_properties():
|
|||
query = query.filter(Property.city_id == city_id)
|
||||
if neighborhood_id:
|
||||
query = query.filter(Property.neighborhood_id == neighborhood_id)
|
||||
if type_filter in ("venda", "aluguel"):
|
||||
query = query.filter(Property.type == type_filter)
|
||||
if is_active_raw == "true":
|
||||
query = query.filter(Property.is_active.is_(True))
|
||||
elif is_active_raw == "false":
|
||||
query = query.filter(Property.is_active.is_(False))
|
||||
|
||||
total = query.count()
|
||||
props = (
|
||||
|
|
@ -973,17 +983,16 @@ def list_leads():
|
|||
page = max(1, request.args.get("page", 1, type=int))
|
||||
per_page = min(100, max(1, request.args.get("per_page", 20, type=int)))
|
||||
property_id = request.args.get("property_id")
|
||||
source = request.args.get("source")
|
||||
|
||||
q = db.select(ContactLead).order_by(ContactLead.created_at.desc())
|
||||
if property_id:
|
||||
q = q.where(ContactLead.property_id == property_id)
|
||||
if source:
|
||||
q = q.where(ContactLead.source == source)
|
||||
|
||||
total = db.session.scalar(
|
||||
db.select(db.func.count()).select_from(q.subquery())
|
||||
)
|
||||
leads = db.session.scalars(
|
||||
q.limit(per_page).offset((page - 1) * per_page)
|
||||
).all()
|
||||
total = db.session.scalar(db.select(db.func.count()).select_from(q.subquery()))
|
||||
leads = db.session.scalars(q.limit(per_page).offset((page - 1) * per_page)).all()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
|
|
@ -995,6 +1004,8 @@ def list_leads():
|
|||
"email": lead.email,
|
||||
"phone": lead.phone,
|
||||
"message": lead.message,
|
||||
"source": lead.source,
|
||||
"source_detail": lead.source_detail,
|
||||
"created_at": lead.created_at.isoformat(),
|
||||
}
|
||||
for lead in leads
|
||||
|
|
@ -1005,3 +1016,46 @@ def list_leads():
|
|||
"pages": max(1, -(-total // per_page)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── Contact config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@admin_bp.get("/contact-config")
|
||||
@require_admin
|
||||
def get_contact_config_admin():
|
||||
from app.models.contact_config import ContactConfig
|
||||
from app.schemas.contact_config import ContactConfigOut
|
||||
|
||||
config = ContactConfig.query.first()
|
||||
if config is None:
|
||||
return jsonify({}), 200
|
||||
return jsonify(ContactConfigOut.model_validate(config).model_dump())
|
||||
|
||||
|
||||
@admin_bp.put("/contact-config")
|
||||
@require_admin
|
||||
def update_contact_config():
|
||||
from app.models.contact_config import ContactConfig
|
||||
from app.schemas.contact_config import ContactConfigIn, ContactConfigOut
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
cfg_in = ContactConfigIn.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return jsonify({"error": "Dados inválidos", "details": exc.errors()}), 422
|
||||
|
||||
config = ContactConfig.query.first()
|
||||
if config is None:
|
||||
config = ContactConfig(id=1)
|
||||
db.session.add(config)
|
||||
|
||||
config.address_street = cfg_in.address_street
|
||||
config.address_neighborhood_city = cfg_in.address_neighborhood_city
|
||||
config.address_zip = cfg_in.address_zip
|
||||
config.phone = cfg_in.phone
|
||||
config.email = cfg_in.email
|
||||
config.business_hours = cfg_in.business_hours
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(ContactConfigOut.model_validate(config).model_dump())
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import uuid as _uuid
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app import db
|
||||
from app.models.saved_property import SavedProperty
|
||||
from app.models.visit_request import VisitRequest
|
||||
from app.models.boleto import Boleto
|
||||
from app.models.property import Property
|
||||
from app.models.user import ClientUser
|
||||
from app.schemas.client_area import (
|
||||
SavedPropertyOut,
|
||||
PropertyCard,
|
||||
FavoriteIn,
|
||||
VisitRequestOut,
|
||||
BoletoOut,
|
||||
UpdateProfileIn,
|
||||
UpdateProfileOut,
|
||||
UpdatePasswordIn,
|
||||
)
|
||||
from app.utils.auth import require_auth
|
||||
|
||||
|
|
@ -22,13 +29,15 @@ client_bp = Blueprint("client", __name__)
|
|||
@require_auth
|
||||
def get_favorites():
|
||||
saved = (
|
||||
SavedProperty.query.filter_by(user_id=g.current_user_id)
|
||||
SavedProperty.query
|
||||
.filter_by(user_id=g.current_user_id)
|
||||
.options(selectinload(SavedProperty.property).selectinload(Property.photos))
|
||||
.order_by(SavedProperty.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return (
|
||||
jsonify(
|
||||
[SavedPropertyOut.model_validate(s).model_dump(mode="json") for s in saved]
|
||||
[SavedPropertyOut.from_saved(s).model_dump(mode="json") for s in saved]
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
|
@ -58,7 +67,7 @@ def add_favorite():
|
|||
db.session.rollback()
|
||||
return jsonify({"error": "Imóvel já está nos favoritos"}), 409
|
||||
|
||||
return jsonify(SavedPropertyOut.model_validate(saved).model_dump(mode="json")), 201
|
||||
return jsonify(SavedPropertyOut.from_saved(saved).model_dump(mode="json")), 201
|
||||
|
||||
|
||||
@client_bp.delete("/favorites/<property_id>")
|
||||
|
|
@ -107,3 +116,64 @@ def get_boletos():
|
|||
jsonify([BoletoOut.model_validate(b).model_dump(mode="json") for b in boletos]),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@client_bp.patch("/profile")
|
||||
@require_auth
|
||||
def update_profile():
|
||||
try:
|
||||
data = UpdateProfileIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
user = db.session.get(ClientUser, g.current_user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
||||
|
||||
user.name = data.name
|
||||
db.session.commit()
|
||||
return jsonify(UpdateProfileOut.model_validate(user).model_dump(mode="json")), 200
|
||||
|
||||
|
||||
@client_bp.patch("/password")
|
||||
@require_auth
|
||||
def change_password():
|
||||
try:
|
||||
data = UpdatePasswordIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
user = db.session.get(ClientUser, g.current_user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Usuário não encontrado"}), 404
|
||||
|
||||
if not bcrypt.checkpw(
|
||||
data.current_password.encode("utf-8"),
|
||||
user.password_hash.encode("utf-8"),
|
||||
):
|
||||
return jsonify({"error": "Senha atual incorreta"}), 400
|
||||
|
||||
user.password_hash = bcrypt.hashpw(
|
||||
data.new_password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
db.session.commit()
|
||||
return "", 204
|
||||
|
||||
|
||||
@client_bp.patch("/visits/<visit_id>/cancel")
|
||||
@require_auth
|
||||
def cancel_visit(visit_id: str):
|
||||
visit = db.session.get(VisitRequest, visit_id)
|
||||
if not visit:
|
||||
return jsonify({"error": "Visita não encontrada"}), 404
|
||||
|
||||
if str(visit.user_id) != str(g.current_user_id):
|
||||
return jsonify({"error": "Acesso negado"}), 403
|
||||
|
||||
if visit.status != "pending":
|
||||
return jsonify({"error": "Apenas visitas pendentes podem ser canceladas"}), 400
|
||||
|
||||
visit.status = "cancelled"
|
||||
db.session.commit()
|
||||
return jsonify(VisitRequestOut.model_validate(visit).model_dump(mode="json")), 200
|
||||
|
||||
|
|
|
|||
14
backend/app/routes/contact_config.py
Normal file
14
backend/app/routes/contact_config.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from flask import Blueprint, jsonify
|
||||
|
||||
from app.models.contact_config import ContactConfig
|
||||
from app.schemas.contact_config import ContactConfigOut
|
||||
|
||||
contact_config_bp = Blueprint("contact_config", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@contact_config_bp.get("/contact-config")
|
||||
def get_contact_config():
|
||||
config = ContactConfig.query.first()
|
||||
if config is None:
|
||||
return jsonify({}), 200
|
||||
return jsonify(ContactConfigOut.model_validate(config).model_dump())
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, jsonify, request
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.homepage import HomepageConfig
|
||||
from app.schemas.homepage import HomepageConfigOut
|
||||
from app.schemas.homepage import HomepageConfigOut, HomepageHeroImagesIn
|
||||
from app.utils.auth import require_admin
|
||||
|
||||
homepage_bp = Blueprint("homepage", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
|
@ -11,4 +14,33 @@ def get_homepage_config():
|
|||
config = HomepageConfig.query.first()
|
||||
if config is None:
|
||||
return jsonify({"error": "Homepage config not found"}), 404
|
||||
return jsonify(HomepageConfigOut.model_validate(config).model_dump())
|
||||
return jsonify(HomepageConfigOut.model_validate(config).model_dump(mode="json"))
|
||||
|
||||
|
||||
@homepage_bp.put("/admin/homepage-config")
|
||||
@require_admin
|
||||
def update_homepage_hero_images():
|
||||
try:
|
||||
data = HomepageHeroImagesIn.model_validate(request.get_json() or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": e.errors(include_url=False)}), 422
|
||||
|
||||
config = HomepageConfig.query.first()
|
||||
if config is None:
|
||||
config = HomepageConfig(
|
||||
hero_headline="Encontre o imóvel dos seus sonhos",
|
||||
hero_subheadline="Os melhores imóveis para comprar ou alugar na sua região",
|
||||
hero_cta_label="Ver Imóveis",
|
||||
hero_cta_url="/imoveis",
|
||||
featured_properties_limit=6,
|
||||
)
|
||||
db.session.add(config)
|
||||
|
||||
config.hero_image_url = data.hero_image_url
|
||||
config.hero_image_light_url = data.hero_image_light_url
|
||||
config.hero_image_dark_url = data.hero_image_dark_url
|
||||
|
||||
db.session.commit()
|
||||
db.session.refresh(config)
|
||||
|
||||
return jsonify(HomepageConfigOut.model_validate(config).model_dump(mode="json")), 200
|
||||
|
|
|
|||
56
backend/app/routes/jobs.py
Normal file
56
backend/app/routes/jobs.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import json as _json
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.job_application import JobApplication
|
||||
from app.schemas.job_application import JobApplicationIn, JobApplicationOut
|
||||
from app.utils.auth import require_admin
|
||||
|
||||
jobs_public_bp = Blueprint("jobs_public", __name__, url_prefix="/api/v1")
|
||||
jobs_admin_bp = Blueprint("jobs_admin", __name__, url_prefix="/api/v1/admin")
|
||||
|
||||
|
||||
@jobs_public_bp.route("/jobs/apply", methods=["POST"])
|
||||
def apply():
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
validated = JobApplicationIn.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return jsonify({"error": "Dados inválidos", "details": _json.loads(exc.json())}), 422
|
||||
|
||||
application = JobApplication(
|
||||
name=validated.name,
|
||||
email=validated.email,
|
||||
phone=validated.phone,
|
||||
role_interest=validated.role_interest,
|
||||
message=validated.message,
|
||||
file_name=validated.file_name,
|
||||
)
|
||||
db.session.add(application)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Candidatura recebida com sucesso"}), 201
|
||||
|
||||
|
||||
@jobs_admin_bp.route("/jobs", methods=["GET"])
|
||||
@require_admin
|
||||
def list_applications():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
|
||||
pagination = (
|
||||
JobApplication.query
|
||||
.order_by(JobApplication.created_at.desc())
|
||||
.paginate(page=page, per_page=per_page, error_out=False)
|
||||
)
|
||||
|
||||
items = [JobApplicationOut.model_validate(item).model_dump(mode="json") for item in pagination.items]
|
||||
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"total": pagination.total,
|
||||
"page": pagination.page,
|
||||
"per_page": pagination.per_page,
|
||||
"pages": pagination.pages,
|
||||
}), 200
|
||||
|
|
@ -83,7 +83,9 @@ def list_properties():
|
|||
neighborhood_ids_raw = args.get("neighborhood_ids", "")
|
||||
if neighborhood_ids_raw:
|
||||
try:
|
||||
neighborhood_ids_list = [int(x) for x in neighborhood_ids_raw.split(",") if x.strip()]
|
||||
neighborhood_ids_list = [
|
||||
int(x) for x in neighborhood_ids_raw.split(",") if x.strip()
|
||||
]
|
||||
except ValueError:
|
||||
neighborhood_ids_list = []
|
||||
if neighborhood_ids_list:
|
||||
|
|
@ -171,16 +173,14 @@ def list_properties():
|
|||
if q_raw:
|
||||
pattern = f"%{q_raw}%"
|
||||
NeighborhoodAlias = aliased(Neighborhood)
|
||||
query = (
|
||||
query
|
||||
.outerjoin(NeighborhoodAlias, Property.neighborhood_id == NeighborhoodAlias.id)
|
||||
.filter(
|
||||
or_(
|
||||
Property.title.ilike(pattern),
|
||||
Property.address.ilike(pattern),
|
||||
Property.code.ilike(pattern),
|
||||
NeighborhoodAlias.name.ilike(pattern),
|
||||
)
|
||||
query = query.outerjoin(
|
||||
NeighborhoodAlias, Property.neighborhood_id == NeighborhoodAlias.id
|
||||
).filter(
|
||||
or_(
|
||||
Property.title.ilike(pattern),
|
||||
Property.address.ilike(pattern),
|
||||
Property.code.ilike(pattern),
|
||||
NeighborhoodAlias.name.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -203,10 +203,7 @@ def list_properties():
|
|||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
props = (
|
||||
query.order_by(sort_order)
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
.all()
|
||||
query.order_by(sort_order).offset((page - 1) * per_page).limit(per_page).all()
|
||||
)
|
||||
|
||||
result = PaginatedPropertiesOut(
|
||||
|
|
@ -248,6 +245,46 @@ def contact_property(slug: str):
|
|||
email=lead_in.email,
|
||||
phone=lead_in.phone,
|
||||
message=lead_in.message,
|
||||
source=lead_in.source or "imovel",
|
||||
source_detail=lead_in.source_detail or prop.title,
|
||||
)
|
||||
db.session.add(lead)
|
||||
db.session.commit()
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
ContactLeadCreatedOut(
|
||||
id=lead.id, message="Mensagem enviada com sucesso!"
|
||||
).model_dump()
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@properties_bp.post("/contact")
|
||||
def contact_general():
|
||||
"""Contato geral (página /contato e /cadastro-residencia)."""
|
||||
from app.models.lead import ContactLead
|
||||
from app.schemas.lead import ContactLeadIn, ContactLeadCreatedOut
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
lead_in = ContactLeadIn.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
import json as _json
|
||||
return jsonify({"error": "Dados inválidos", "details": _json.loads(exc.json())}), 422
|
||||
|
||||
valid_sources = {"contato", "imovel", "cadastro_residencia"}
|
||||
source = lead_in.source if lead_in.source in valid_sources else "contato"
|
||||
|
||||
lead = ContactLead(
|
||||
property_id=None,
|
||||
name=lead_in.name,
|
||||
email=lead_in.email,
|
||||
phone=lead_in.phone,
|
||||
message=lead_in.message,
|
||||
source=source,
|
||||
source_detail=lead_in.source_detail,
|
||||
)
|
||||
db.session.add(lead)
|
||||
db.session.commit()
|
||||
|
|
|
|||
|
|
@ -13,14 +13,51 @@ class PropertyBrief(BaseModel):
|
|||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PropertyCard(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
slug: str
|
||||
price: Optional[Decimal] = None
|
||||
city: Optional[str] = None
|
||||
neighborhood: Optional[str] = None
|
||||
cover_photo_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_property(cls, prop) -> "PropertyCard":
|
||||
cover = prop.photos[0].url if prop.photos else None
|
||||
city = prop.city.name if prop.city else None
|
||||
neighborhood = prop.neighborhood.name if prop.neighborhood else None
|
||||
return cls(
|
||||
id=str(prop.id),
|
||||
title=prop.title,
|
||||
slug=prop.slug,
|
||||
price=prop.price,
|
||||
city=city,
|
||||
neighborhood=neighborhood,
|
||||
cover_photo_url=cover,
|
||||
)
|
||||
|
||||
|
||||
class SavedPropertyOut(BaseModel):
|
||||
id: str
|
||||
property_id: Optional[str]
|
||||
property: Optional[PropertyBrief]
|
||||
property: Optional[PropertyCard]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_saved(cls, saved) -> "SavedPropertyOut":
|
||||
prop_card = PropertyCard.from_property(saved.property) if saved.property else None
|
||||
return cls(
|
||||
id=str(saved.id),
|
||||
property_id=str(saved.property_id) if saved.property_id else None,
|
||||
property=prop_card,
|
||||
created_at=saved.created_at,
|
||||
)
|
||||
|
||||
|
||||
class FavoriteIn(BaseModel):
|
||||
property_id: str
|
||||
|
|
@ -93,3 +130,34 @@ class BoletoCreateIn(BaseModel):
|
|||
if v <= 0:
|
||||
raise ValueError("Valor deve ser positivo")
|
||||
return v
|
||||
|
||||
|
||||
class UpdateProfileIn(BaseModel):
|
||||
name: str
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class UpdateProfileOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdatePasswordIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def min_length(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("A nova senha deve ter pelo menos 8 caracteres")
|
||||
return v
|
||||
|
|
|
|||
43
backend/app/schemas/contact_config.py
Normal file
43
backend/app/schemas/contact_config.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
class ContactConfigOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address_street: str | None = None
|
||||
address_neighborhood_city: str | None = None
|
||||
address_zip: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
business_hours: str | None = None
|
||||
|
||||
|
||||
class ContactConfigIn(BaseModel):
|
||||
address_street: str | None = None
|
||||
address_neighborhood_city: str | None = None
|
||||
address_zip: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
business_hours: str | None = None
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
import re
|
||||
|
||||
v = v.strip().lower()
|
||||
if v and not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", v):
|
||||
raise ValueError("E-mail inválido.")
|
||||
return v or None
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def validate_phone(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
return v or None
|
||||
|
|
@ -12,6 +12,8 @@ class HomepageConfigOut(BaseModel):
|
|||
hero_cta_url: str
|
||||
featured_properties_limit: int
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
|
||||
class HomepageConfigIn(BaseModel):
|
||||
|
|
@ -21,6 +23,8 @@ class HomepageConfigIn(BaseModel):
|
|||
hero_cta_url: str = "/imoveis"
|
||||
featured_properties_limit: int = 6
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
@field_validator("hero_headline")
|
||||
@classmethod
|
||||
|
|
@ -35,3 +39,17 @@ class HomepageConfigIn(BaseModel):
|
|||
if not (1 <= v <= 12):
|
||||
raise ValueError("featured_properties_limit deve estar entre 1 e 12")
|
||||
return v
|
||||
|
||||
|
||||
class HomepageHeroImagesIn(BaseModel):
|
||||
hero_image_url: str | None = None
|
||||
hero_image_light_url: str | None = None
|
||||
hero_image_dark_url: str | None = None
|
||||
|
||||
@field_validator("hero_image_url", "hero_image_light_url", "hero_image_dark_url")
|
||||
@classmethod
|
||||
def normalize_empty_to_none(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
trimmed = v.strip()
|
||||
return trimmed or None
|
||||
|
|
|
|||
59
backend/app/schemas/job_application.py
Normal file
59
backend/app/schemas/job_application.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
|
||||
|
||||
ROLE_INTEREST_OPTIONS = [
|
||||
"Corretor(a)",
|
||||
"Assistente Administrativo",
|
||||
"Estagiário(a)",
|
||||
"Outro",
|
||||
]
|
||||
|
||||
|
||||
class JobApplicationIn(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
phone: Optional[str] = None
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: Optional[str] = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Nome não pode ser vazio")
|
||||
return v
|
||||
|
||||
@field_validator("role_interest")
|
||||
@classmethod
|
||||
def valid_role(cls, v: str) -> str:
|
||||
if v not in ROLE_INTEREST_OPTIONS:
|
||||
raise ValueError(f"Cargo inválido. Opções: {ROLE_INTEREST_OPTIONS}")
|
||||
return v
|
||||
|
||||
@field_validator("message")
|
||||
@classmethod
|
||||
def message_not_empty(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Mensagem não pode ser vazia")
|
||||
if len(v) > 5000:
|
||||
raise ValueError("Mensagem não pode ultrapassar 5000 caracteres")
|
||||
return v
|
||||
|
||||
|
||||
class JobApplicationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
phone: Optional[str]
|
||||
role_interest: str
|
||||
message: str
|
||||
file_name: Optional[str]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
|
@ -13,6 +13,8 @@ class ContactLeadIn(BaseModel):
|
|||
email: str
|
||||
phone: str | None = None
|
||||
message: str
|
||||
source: str | None = None
|
||||
source_detail: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ from typing import Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.catalog import AmenityOut, ImobiliariaOut, PropertyTypeOut, CityOut, NeighborhoodOut
|
||||
from app.schemas.catalog import (
|
||||
AmenityOut,
|
||||
ImobiliariaOut,
|
||||
PropertyTypeOut,
|
||||
CityOut,
|
||||
NeighborhoodOut,
|
||||
)
|
||||
|
||||
|
||||
class PropertyPhotoOut(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
420
backend/tests/test_contact_flow.py
Normal file
420
backend/tests/test_contact_flow.py
Normal 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"]
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
95
frontend/src/components/FavoritesCardsGrid.tsx
Normal file
95
frontend/src/components/FavoritesCardsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
22
frontend/src/hooks/useInView.ts
Normal file
22
frontend/src/hooks/useInView.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
432
frontend/src/pages/CadastroResidenciaPage.tsx
Normal file
432
frontend/src/pages/CadastroResidenciaPage.tsx
Normal 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} m²`)
|
||||
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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
260
frontend/src/pages/ContactPage.tsx
Normal file
260
frontend/src/pages/ContactPage.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
316
frontend/src/pages/JobsPage.tsx
Normal file
316
frontend/src/pages/JobsPage.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
78
frontend/src/pages/PublicFavoritesPage.tsx
Normal file
78
frontend/src/pages/PublicFavoritesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
frontend/src/pages/admin/AdminContactConfigPage.tsx
Normal file
208
frontend/src/pages/admin/AdminContactConfigPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
382
frontend/src/pages/admin/AdminHomepageConfigPage.tsx
Normal file
382
frontend/src/pages/admin/AdminHomepageConfigPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
frontend/src/pages/admin/AdminJobsPage.tsx
Normal file
231
frontend/src/pages/admin/AdminJobsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
frontend/src/pages/admin/AdminLeadsPage.tsx
Normal file
237
frontend/src/pages/admin/AdminLeadsPage.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
153
frontend/src/pages/client/ProfilePage.tsx
Normal file
153
frontend/src/pages/client/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
20
frontend/src/services/contactConfig.ts
Normal file
20
frontend/src/services/contactConfig.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
14
frontend/src/services/jobs.ts
Normal file
14
frontend/src/services/jobs.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,4 +50,6 @@ export interface ContactFormData {
|
|||
email: string
|
||||
phone: string
|
||||
message: string
|
||||
source?: string
|
||||
source_detail?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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)}`.
|
||||
|
|
|
|||
36
specs/025-favoritos-locais/checklists/requirements.md
Normal file
36
specs/025-favoritos-locais/checklists/requirements.md
Normal 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.
|
||||
255
specs/025-favoritos-locais/plan.md
Normal file
255
specs/025-favoritos-locais/plan.md
Normal 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
|
||||
166
specs/025-favoritos-locais/spec.md
Normal file
166
specs/025-favoritos-locais/spec.md
Normal 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.
|
||||
265
specs/025-favoritos-locais/tasks.md
Normal file
265
specs/025-favoritos-locais/tasks.md
Normal 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 (T001–T010): visitante favorita imóveis localmente e acessa a página `/favoritos`.
|
||||
|
||||
Fases 4–5 (T011–T012) entregam as histórias P2 (banner + merge no login).
|
||||
|
||||
---
|
||||
|
||||
## Contagem de Tasks
|
||||
|
||||
| Fase | Tasks | User Story |
|
||||
|------|-------|-----------|
|
||||
| Foundational | T001, T002 | — |
|
||||
| US1 | T003–T008 | P1 |
|
||||
| US2 | T009–T010 | P1 |
|
||||
| US3 | T011 | P2 |
|
||||
| US4 | T012 | P2 |
|
||||
| Polish | T013–T014 | — |
|
||||
| **Total** | **14 tasks** | |
|
||||
36
specs/026-central-contatos/checklists/requirements.md
Normal file
36
specs/026-central-contatos/checklists/requirements.md
Normal 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.
|
||||
201
specs/026-central-contatos/spec.md
Normal file
201
specs/026-central-contatos/spec.md
Normal 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.
|
||||
206
specs/026-central-contatos/tasks.md
Normal file
206
specs/026-central-contatos/tasks.md
Normal 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 US1–US5 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.
|
||||
35
specs/027-config-pagina-contato/checklists/requirements.md
Normal file
35
specs/027-config-pagina-contato/checklists/requirements.md
Normal 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`.
|
||||
151
specs/027-config-pagina-contato/spec.md
Normal file
151
specs/027-config-pagina-contato/spec.md
Normal 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.
|
||||
404
specs/027-config-pagina-contato/tasks.md
Normal file
404
specs/027-config-pagina-contato/tasks.md
Normal 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 T001–T004
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
36
specs/028-trabalhe-conosco/checklists/requirements.md
Normal file
36
specs/028-trabalhe-conosco/checklists/requirements.md
Normal 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`
|
||||
210
specs/028-trabalhe-conosco/contracts/jobs-api.md
Normal file
210
specs/028-trabalhe-conosco/contracts/jobs-api.md
Normal 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`.
|
||||
215
specs/028-trabalhe-conosco/data-model.md
Normal file
215
specs/028-trabalhe-conosco/data-model.md
Normal 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
|
||||
```
|
||||
354
specs/028-trabalhe-conosco/plan.md
Normal file
354
specs/028-trabalhe-conosco/plan.md
Normal 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 |
|
||||
|-----------|------------|--------------------------------------|
|
||||
| — | — | — |
|
||||
150
specs/028-trabalhe-conosco/spec.md
Normal file
150
specs/028-trabalhe-conosco/spec.md
Normal 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.
|
||||
217
specs/028-trabalhe-conosco/tasks.md
Normal file
217
specs/028-trabalhe-conosco/tasks.md
Normal 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 1–100), 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 | — | T001–T006, 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).
|
||||
36
specs/029-ux-area-do-cliente/checklists/requirements.md
Normal file
36
specs/029-ux-area-do-cliente/checklists/requirements.md
Normal 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`.
|
||||
118
specs/029-ux-area-do-cliente/plan.md
Normal file
118
specs/029-ux-area-do-cliente/plan.md
Normal 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.
|
||||
203
specs/029-ux-area-do-cliente/spec.md
Normal file
203
specs/029-ux-area-do-cliente/spec.md
Normal 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.
|
||||
994
specs/029-ux-area-do-cliente/tasks.md
Normal file
994
specs/029-ux-area-do-cliente/tasks.md
Normal 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 T03–T05.
|
||||
|
||||
**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 T01–T08
|
||||
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`
|
||||
173
specs/029-ux-area-do-cliente/ux-audit.md
Normal file
173
specs/029-ux-area-do-cliente/ux-audit.md
Normal 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
|
||||
191
specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md
Normal file
191
specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md
Normal 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
|
||||
165
specs/030-navbar-topo-ux/data-model.md
Normal file
165
specs/030-navbar-topo-ux/data-model.md
Normal 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
|
||||
```
|
||||
123
specs/030-navbar-topo-ux/plan.md
Normal file
123
specs/030-navbar-topo-ux/plan.md
Normal 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.
|
||||
|
||||
105
specs/030-navbar-topo-ux/quickstart.md
Normal file
105
specs/030-navbar-topo-ux/quickstart.md
Normal 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.
|
||||
71
specs/030-navbar-topo-ux/research.md
Normal file
71
specs/030-navbar-topo-ux/research.md
Normal 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.
|
||||
195
specs/030-navbar-topo-ux/spec.md
Normal file
195
specs/030-navbar-topo-ux/spec.md
Normal 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.
|
||||
261
specs/030-navbar-topo-ux/tasks.md
Normal file
261
specs/030-navbar-topo-ux/tasks.md
Normal 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 | T001–T002 | Setup da navbar compartilhada |
|
||||
| Phase 2 | T003–T006 | Estado e shell bloqueadores |
|
||||
| Phase 3 | T007–T010 | US1 — visitante |
|
||||
| Phase 4 | T011–T014 | US2 — cliente autenticado |
|
||||
| Phase 5 | T015–T017 | US3 — admin |
|
||||
| Phase 6 | T018–T020 | US4 — acessibilidade e previsibilidade |
|
||||
| Phase 7 | T021–T023 | US5 — responsividade premium |
|
||||
| Phase Final | T024–T026 | Build, validação manual e limpeza |
|
||||
| **Total** | **26 tarefas** | **5 user stories + setup/foundational/polish** |
|
||||
|
||||
28
specs/031-home-hero-light-dark/plan.md
Normal file
28
specs/031-home-hero-light-dark/plan.md
Normal 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
Loading…
Add table
Add a link
Reference in a new issue