From cf5603243ca33f66723fcedb3f485424b10d2d7a Mon Sep 17 00:00:00 2001 From: MatheusAlves96 Date: Wed, 22 Apr 2026 22:35:17 -0300 Subject: [PATCH] feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .github/copilot-instructions.md | 6 +- .specify/feature.json | 2 +- backend/app/__init__.py | 8 + backend/app/models/__init__.py | 1 + backend/app/models/contact_config.py | 22 + backend/app/models/homepage.py | 2 + backend/app/models/job_application.py | 27 + backend/app/models/lead.py | 6 +- backend/app/routes/admin.py | 66 +- backend/app/routes/client_area.py | 76 +- backend/app/routes/contact_config.py | 14 + backend/app/routes/homepage.py | 38 +- backend/app/routes/jobs.py | 56 + backend/app/routes/properties.py | 67 +- backend/app/schemas/client_area.py | 70 +- backend/app/schemas/contact_config.py | 43 + backend/app/schemas/homepage.py | 18 + backend/app/schemas/job_application.py | 59 ++ backend/app/schemas/lead.py | 2 + backend/app/schemas/property.py | 8 +- ...1h2i3j4k5l6_add_source_to_contact_leads.py | 32 + .../h1i2j3k4l5m6_create_contact_config.py | 55 + .../i1j2k3l4m5n6_add_job_applications.py | 44 + ...l4m5n6o7_add_homepage_hero_theme_images.py | 31 + backend/seeds/seed.py | 47 + backend/tests/test_contact_flow.py | 420 ++++++++ frontend/src/App.tsx | 33 +- frontend/src/components/AgentsCarousel.tsx | 19 +- .../src/components/FavoritesCardsGrid.tsx | 95 ++ frontend/src/components/FilterSidebar.tsx | 91 +- frontend/src/components/Footer.tsx | 174 ++- frontend/src/components/HeartButton.tsx | 18 +- frontend/src/components/HomeScrollScene.tsx | 124 +-- frontend/src/components/Navbar.tsx | 574 +++++++--- frontend/src/components/PropertyCard.tsx | 7 +- frontend/src/components/PropertyGridCard.tsx | 22 +- frontend/src/components/PropertyRowCard.tsx | 63 +- frontend/src/contexts/AuthContext.tsx | 6 + frontend/src/contexts/FavoritesContext.tsx | 85 +- frontend/src/hooks/useInView.ts | 22 + frontend/src/index.css | 97 +- frontend/src/layouts/ClientLayout.tsx | 129 +-- frontend/src/pages/CadastroResidenciaPage.tsx | 432 ++++++++ frontend/src/pages/ContactPage.tsx | 260 +++++ frontend/src/pages/HomePage.tsx | 68 +- frontend/src/pages/JobsPage.tsx | 316 ++++++ frontend/src/pages/LoginPage.tsx | 165 +-- frontend/src/pages/PropertiesPage.tsx | 226 ++-- frontend/src/pages/PropertyDetailPage.tsx | 5 +- frontend/src/pages/PublicFavoritesPage.tsx | 78 ++ .../pages/admin/AdminContactConfigPage.tsx | 208 ++++ .../pages/admin/AdminHomepageConfigPage.tsx | 382 +++++++ frontend/src/pages/admin/AdminJobsPage.tsx | 231 ++++ frontend/src/pages/admin/AdminLeadsPage.tsx | 237 +++++ .../src/pages/admin/AdminPropertiesPage.tsx | 897 ++++++++-------- frontend/src/pages/client/BoletosPage.tsx | 100 -- .../src/pages/client/ClientDashboardPage.tsx | 53 - frontend/src/pages/client/ComparisonPage.tsx | 19 +- frontend/src/pages/client/FavoritesPage.tsx | 92 +- frontend/src/pages/client/ProfilePage.tsx | 153 +++ frontend/src/pages/client/VisitsPage.tsx | 34 +- frontend/src/services/clientArea.ts | 17 +- frontend/src/services/contactConfig.ts | 20 + frontend/src/services/homepage.ts | 27 +- frontend/src/services/jobs.ts | 14 + frontend/src/services/properties.ts | 7 + frontend/src/types/clientArea.ts | 32 +- frontend/src/types/homepage.ts | 8 + frontend/src/types/property.ts | 2 + .../contracts/api-catalog-enhancements.md | 4 +- specs/024-filtro-busca-avancada/plan.md | 22 +- .../checklists/requirements.md | 36 + specs/025-favoritos-locais/plan.md | 255 +++++ specs/025-favoritos-locais/spec.md | 166 +++ specs/025-favoritos-locais/tasks.md | 265 +++++ .../checklists/requirements.md | 36 + specs/026-central-contatos/spec.md | 201 ++++ specs/026-central-contatos/tasks.md | 206 ++++ .../checklists/requirements.md | 35 + specs/027-config-pagina-contato/spec.md | 151 +++ specs/027-config-pagina-contato/tasks.md | 404 +++++++ .../checklists/requirements.md | 36 + .../contracts/jobs-api.md | 210 ++++ specs/028-trabalhe-conosco/data-model.md | 215 ++++ specs/028-trabalhe-conosco/plan.md | 354 +++++++ specs/028-trabalhe-conosco/spec.md | 150 +++ specs/028-trabalhe-conosco/tasks.md | 217 ++++ .../checklists/requirements.md | 36 + specs/029-ux-area-do-cliente/plan.md | 118 +++ specs/029-ux-area-do-cliente/spec.md | 203 ++++ specs/029-ux-area-do-cliente/tasks.md | 994 ++++++++++++++++++ specs/029-ux-area-do-cliente/ux-audit.md | 173 +++ .../contracts/navbar-ui-contract.md | 191 ++++ specs/030-navbar-topo-ux/data-model.md | 165 +++ specs/030-navbar-topo-ux/plan.md | 123 +++ specs/030-navbar-topo-ux/quickstart.md | 105 ++ specs/030-navbar-topo-ux/research.md | 71 ++ specs/030-navbar-topo-ux/spec.md | 195 ++++ specs/030-navbar-topo-ux/tasks.md | 261 +++++ specs/031-home-hero-light-dark/plan.md | 28 + specs/031-home-hero-light-dark/spec.md | 56 + specs/031-home-hero-light-dark/tasks.md | 12 + .../performance-audit.md | 302 ++++++ specs/032-performance-homepage/plan.md | 136 +++ specs/032-performance-homepage/spec.md | 97 ++ specs/032-performance-homepage/tasks.md | 203 ++++ 106 files changed, 11927 insertions(+), 1367 deletions(-) create mode 100644 backend/app/models/contact_config.py create mode 100644 backend/app/models/job_application.py create mode 100644 backend/app/routes/contact_config.py create mode 100644 backend/app/routes/jobs.py create mode 100644 backend/app/schemas/contact_config.py create mode 100644 backend/app/schemas/job_application.py create mode 100644 backend/migrations/versions/g1h2i3j4k5l6_add_source_to_contact_leads.py create mode 100644 backend/migrations/versions/h1i2j3k4l5m6_create_contact_config.py create mode 100644 backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py create mode 100644 backend/migrations/versions/j2k3l4m5n6o7_add_homepage_hero_theme_images.py create mode 100644 backend/tests/test_contact_flow.py create mode 100644 frontend/src/components/FavoritesCardsGrid.tsx create mode 100644 frontend/src/hooks/useInView.ts create mode 100644 frontend/src/pages/CadastroResidenciaPage.tsx create mode 100644 frontend/src/pages/ContactPage.tsx create mode 100644 frontend/src/pages/JobsPage.tsx create mode 100644 frontend/src/pages/PublicFavoritesPage.tsx create mode 100644 frontend/src/pages/admin/AdminContactConfigPage.tsx create mode 100644 frontend/src/pages/admin/AdminHomepageConfigPage.tsx create mode 100644 frontend/src/pages/admin/AdminJobsPage.tsx create mode 100644 frontend/src/pages/admin/AdminLeadsPage.tsx delete mode 100644 frontend/src/pages/client/BoletosPage.tsx delete mode 100644 frontend/src/pages/client/ClientDashboardPage.tsx create mode 100644 frontend/src/pages/client/ProfilePage.tsx create mode 100644 frontend/src/services/contactConfig.ts create mode 100644 frontend/src/services/jobs.ts create mode 100644 specs/025-favoritos-locais/checklists/requirements.md create mode 100644 specs/025-favoritos-locais/plan.md create mode 100644 specs/025-favoritos-locais/spec.md create mode 100644 specs/025-favoritos-locais/tasks.md create mode 100644 specs/026-central-contatos/checklists/requirements.md create mode 100644 specs/026-central-contatos/spec.md create mode 100644 specs/026-central-contatos/tasks.md create mode 100644 specs/027-config-pagina-contato/checklists/requirements.md create mode 100644 specs/027-config-pagina-contato/spec.md create mode 100644 specs/027-config-pagina-contato/tasks.md create mode 100644 specs/028-trabalhe-conosco/checklists/requirements.md create mode 100644 specs/028-trabalhe-conosco/contracts/jobs-api.md create mode 100644 specs/028-trabalhe-conosco/data-model.md create mode 100644 specs/028-trabalhe-conosco/plan.md create mode 100644 specs/028-trabalhe-conosco/spec.md create mode 100644 specs/028-trabalhe-conosco/tasks.md create mode 100644 specs/029-ux-area-do-cliente/checklists/requirements.md create mode 100644 specs/029-ux-area-do-cliente/plan.md create mode 100644 specs/029-ux-area-do-cliente/spec.md create mode 100644 specs/029-ux-area-do-cliente/tasks.md create mode 100644 specs/029-ux-area-do-cliente/ux-audit.md create mode 100644 specs/030-navbar-topo-ux/contracts/navbar-ui-contract.md create mode 100644 specs/030-navbar-topo-ux/data-model.md create mode 100644 specs/030-navbar-topo-ux/plan.md create mode 100644 specs/030-navbar-topo-ux/quickstart.md create mode 100644 specs/030-navbar-topo-ux/research.md create mode 100644 specs/030-navbar-topo-ux/spec.md create mode 100644 specs/030-navbar-topo-ux/tasks.md create mode 100644 specs/031-home-hero-light-dark/plan.md create mode 100644 specs/031-home-hero-light-dark/spec.md create mode 100644 specs/031-home-hero-light-dark/tasks.md create mode 100644 specs/032-performance-homepage/performance-audit.md create mode 100644 specs/032-performance-homepage/plan.md create mode 100644 specs/032-performance-homepage/spec.md create mode 100644 specs/032-performance-homepage/tasks.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d26ade9..0dd2244 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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) diff --git a/.specify/feature.json b/.specify/feature.json index 6e242bd..35b474e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/023-ux-melhorias-imoveis" + "feature_directory": "specs/029-ux-area-do-cliente" } diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 4333be0..2b2644b 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2393c69..b88232b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/contact_config.py b/backend/app/models/contact_config.py new file mode 100644 index 0000000..0afe51c --- /dev/null +++ b/backend/app/models/contact_config.py @@ -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"" diff --git a/backend/app/models/homepage.py b/backend/app/models/homepage.py index a45cf55..ece0122 100644 --- a/backend/app/models/homepage.py +++ b/backend/app/models/homepage.py @@ -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, diff --git a/backend/app/models/job_application.py b/backend/app/models/job_application.py new file mode 100644 index 0000000..4e0c8e5 --- /dev/null +++ b/backend/app/models/job_application.py @@ -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"" diff --git a/backend/app/models/lead.py b/backend/app/models/lead.py index 0f0290b..7565c14 100644 --- a/backend/app/models/lead.py +++ b/backend/app/models/lead.py @@ -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"" + return f"" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 50dfe5c..86406ed 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -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()) diff --git a/backend/app/routes/client_area.py b/backend/app/routes/client_area.py index f1f7835..6c2f4a3 100644 --- a/backend/app/routes/client_area.py +++ b/backend/app/routes/client_area.py @@ -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/") @@ -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//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 + diff --git a/backend/app/routes/contact_config.py b/backend/app/routes/contact_config.py new file mode 100644 index 0000000..521f531 --- /dev/null +++ b/backend/app/routes/contact_config.py @@ -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()) diff --git a/backend/app/routes/homepage.py b/backend/app/routes/homepage.py index 11ed371..dfe3ce1 100644 --- a/backend/app/routes/homepage.py +++ b/backend/app/routes/homepage.py @@ -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 diff --git a/backend/app/routes/jobs.py b/backend/app/routes/jobs.py new file mode 100644 index 0000000..0f68c30 --- /dev/null +++ b/backend/app/routes/jobs.py @@ -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 diff --git a/backend/app/routes/properties.py b/backend/app/routes/properties.py index 888f57c..439ef9f 100644 --- a/backend/app/routes/properties.py +++ b/backend/app/routes/properties.py @@ -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() diff --git a/backend/app/schemas/client_area.py b/backend/app/schemas/client_area.py index 8235e5b..e28f8b8 100644 --- a/backend/app/schemas/client_area.py +++ b/backend/app/schemas/client_area.py @@ -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 diff --git a/backend/app/schemas/contact_config.py b/backend/app/schemas/contact_config.py new file mode 100644 index 0000000..ff21f64 --- /dev/null +++ b/backend/app/schemas/contact_config.py @@ -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 diff --git a/backend/app/schemas/homepage.py b/backend/app/schemas/homepage.py index 3c1f0e5..77e3ccf 100644 --- a/backend/app/schemas/homepage.py +++ b/backend/app/schemas/homepage.py @@ -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 diff --git a/backend/app/schemas/job_application.py b/backend/app/schemas/job_application.py new file mode 100644 index 0000000..887191d --- /dev/null +++ b/backend/app/schemas/job_application.py @@ -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 diff --git a/backend/app/schemas/lead.py b/backend/app/schemas/lead.py index c5194d2..3d6c954 100644 --- a/backend/app/schemas/lead.py +++ b/backend/app/schemas/lead.py @@ -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 diff --git a/backend/app/schemas/property.py b/backend/app/schemas/property.py index bbe2af8..967bc46 100644 --- a/backend/app/schemas/property.py +++ b/backend/app/schemas/property.py @@ -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): diff --git a/backend/migrations/versions/g1h2i3j4k5l6_add_source_to_contact_leads.py b/backend/migrations/versions/g1h2i3j4k5l6_add_source_to_contact_leads.py new file mode 100644 index 0000000..a6bb750 --- /dev/null +++ b/backend/migrations/versions/g1h2i3j4k5l6_add_source_to_contact_leads.py @@ -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") diff --git a/backend/migrations/versions/h1i2j3k4l5m6_create_contact_config.py b/backend/migrations/versions/h1i2j3k4l5m6_create_contact_config.py new file mode 100644 index 0000000..edb301e --- /dev/null +++ b/backend/migrations/versions/h1i2j3k4l5m6_create_contact_config.py @@ -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") diff --git a/backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py b/backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py new file mode 100644 index 0000000..ba14ed6 --- /dev/null +++ b/backend/migrations/versions/i1j2k3l4m5n6_add_job_applications.py @@ -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") diff --git a/backend/migrations/versions/j2k3l4m5n6o7_add_homepage_hero_theme_images.py b/backend/migrations/versions/j2k3l4m5n6o7_add_homepage_hero_theme_images.py new file mode 100644 index 0000000..66a515e --- /dev/null +++ b/backend/migrations/versions/j2k3l4m5n6o7_add_homepage_hero_theme_images.py @@ -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") diff --git a/backend/seeds/seed.py b/backend/seeds/seed.py index 0dc36c1..54e57b5 100644 --- a/backend/seeds/seed.py +++ b/backend/seeds/seed.py @@ -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) diff --git a/backend/tests/test_contact_flow.py b/backend/tests/test_contact_flow.py new file mode 100644 index 0000000..44c5c32 --- /dev/null +++ b/backend/tests/test_contact_flow.py @@ -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//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= +""" + +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//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"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 983d8bc..626c52d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } > - } /> + } /> } /> } /> } /> - } /> + } /> } /> } /> } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/components/AgentsCarousel.tsx b/frontend/src/components/AgentsCarousel.tsx index 1c3ccdb..32e5c54 100644 --- a/frontend/src/components/AgentsCarousel.tsx +++ b/frontend/src/components/AgentsCarousel.tsx @@ -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([]) - 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 | null>(null) const trackRef = useRef(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() {
{slides.map((agent, i) => ( diff --git a/frontend/src/components/FavoritesCardsGrid.tsx b/frontend/src/components/FavoritesCardsGrid.tsx new file mode 100644 index 0000000..f781c8f --- /dev/null +++ b/frontend/src/components/FavoritesCardsGrid.tsx @@ -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 ( +
+ + + +

Nenhum favorito ainda

+ + Explorar imóveis → + +
+ ); + } + + return ( +
+ {entries.map(entry => ( +
+
+ {entry.photo ? ( + {entry.title} + ) : ( +
+ + + +
+ )} +
+ +
+ + {entry.type === 'venda' ? 'Venda' : 'Aluguel'} + +
+ +
+ +

+ {entry.title} +

+ {entry.city && ( +

{entry.city}

+ )} + {entry.price && ( +

+ {formatPrice(entry.price, entry.type)} +

+ )} +
+ {entry.bedrooms > 0 && {entry.bedrooms} qto{entry.bedrooms !== 1 ? 's' : ''}} + {entry.area_m2 > 0 && {entry.area_m2} m²} +
+ +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/FilterSidebar.tsx b/frontend/src/components/FilterSidebar.tsx index d76b386..9a95cb9 100644 --- a/frontend/src/components/FilterSidebar.tsx +++ b/frontend/src/components/FilterSidebar.tsx @@ -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} @@ -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} @@ -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} @@ -733,7 +733,7 @@ export default function FilterSidebar({ open={openSections['imobiliaria']} onToggle={() => toggleSection('imobiliaria')} > -
+
{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} + {imob.name} + {isActive && ( + + )} ) })} @@ -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 ? ( -
+
{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} - {city.state} + {city.name}{city.state} + {isActive && ( + + )} ) })} @@ -817,19 +822,22 @@ export default function FilterSidebar({ renderItem={(nbh, isPopular) => { const isActive = (filters.neighborhood_ids ?? []).includes(nbh.id) return ( - - - + + {isActive && ( + + )} + ) }} /> @@ -879,19 +887,22 @@ export default function FilterSidebar({ renderItem={(sub, isPopular) => { const isActive = (filters.subtype_ids ?? []).includes(sub.id) return ( - - - + + {isActive && ( + + )} + ) }} /> @@ -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 && ( diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 8429deb..b5f8697 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -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 ( +
+

+ {title} +

+
    + {children} +
+
+ ) +} + +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
  • {children}
  • + return
  • {children}
  • +} + +function InstagramIcon() { + return ( + + ) +} +function FacebookIcon() { + return ( + + ) +} +function WhatsAppIcon() { + return ( + + ) +} + export default function Footer() { return (
    -
    -
    - {/* Brand */} -
    -
    - + {/* Main grid */} +
    +
    + + {/* Brand — ocupa 2 colunas no lg */} +
    + + I ImobiliáriaHub -
    -

    - Conectando pessoas aos melhores imóveis da região desde 2014. + +

    + Conectamos você ao imóvel ideal com segurança, transparência e agilidade.

    + {/* Redes sociais */} +
    - {/* Nav links */} - + {/* Institucional */} + + Quem somos + Equipe + Trabalhe Conosco + Fale conosco + Política de Privacidade + + + {/* Imóveis */} + + Imóveis para comprar + Imóveis para alugar + Anunciar seu imóvel + Favoritos + + + {/* Atendimento */} + + (11) 99999-9999 + contato@imobiliariahub.com.br +
  • + Rua Exemplo, 1000 — Centro
    CEP: 01310-100 +
  • +
    - {/* Contact */} -
    +
    -
    -

    + {/* Bottom bar */} +

    +
    +

    © {currentYear} ImobiliáriaHub. Todos os direitos reservados.

    + + Política de Privacidade +
    diff --git a/frontend/src/components/HeartButton.tsx b/frontend/src/components/HeartButton.tsx index 0dee26d..078661b 100644 --- a/frontend/src/components/HeartButton.tsx +++ b/frontend/src/components/HeartButton.tsx @@ -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}`} > (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 (
    - {label} + {label}
    {[0, 1, 2].map((i) => ( ([]) - 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 */} - -
    {/* ── Imagem de fundo sticky ───────────────────────────────────── */}
    @@ -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" /> ) : (
    )} @@ -151,8 +136,9 @@ export default function HomeScrollScene({
    @@ -160,20 +146,28 @@ export default function HomeScrollScene({
    {isLoading ? (
    -
    -
    -
    +
    +
    +
    ) : (

    {headline}

    {subheadline && ( -

    +

    {subheadline}

    )} @@ -191,7 +185,7 @@ export default function HomeScrollScene({
    {/* Indicador de rolar */} - +
    {/* ── Seção de imóveis que sobe sobre a imagem ─────────────────── */} @@ -200,30 +194,36 @@ export default function HomeScrollScene({
    {/* Cabeçalho da seção */}

    Imóveis em Destaque

    -

    +

    Selecionados especialmente para você

    {/* Cards */}
    - {loading + {loadingProperties ? Array.from({ length: 3 }).map((_, i) => ) : properties.map((p, i) => ( @@ -234,7 +234,7 @@ export default function HomeScrollScene({
    {/* CTA direto para /imoveis */} - {!loading && ( + {!loadingProperties && (
    +} + +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 ( + { + e.preventDefault() + window.history.pushState({}, '', link.href) + window.dispatchEvent(new PopStateEvent('popstate')) + onClick?.() + }} + className={`navbar-link ${isActive ? 'navbar-link--active' : ''}`} + > + {link.label} + + ) + } + return ( + `navbar-link ${a ? 'navbar-link--active' : ''}`} + onClick={onClick} + > + {link.label} + + ) +} + +function MobilePublicNavItem({ link, onClick }: { link: NavLinkDef; onClick: () => void }) { + const isActive = useQueryNavActive(link) + if (link.matchPath) { + return ( + { + e.preventDefault() + window.history.pushState({}, '', link.href) + window.dispatchEvent(new PopStateEvent('popstate')) + onClick() + }} + className={`navbar-mobile-link ${isActive ? 'navbar-mobile-link--active' : ''}`} + > + {link.label} + + ) + } + return ( + `navbar-mobile-link ${a ? 'navbar-mobile-link--active' : ''}`} + onClick={onClick} + > + {link.label} + + ) +} + +function FavoritesNavLink({ href }: { href: string }) { + const { favoriteIds } = useFavorites() + const count = favoriteIds.size + return ( + 0 ? `Favoritos — ${count} imóvel${count > 1 ? 'is' : ''} salvo${count > 1 ? 's' : ''}` : 'Favoritos'} + > + + Favoritos + {count > 0 && ( + + )} + + ) +} + +// ─── 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('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(null) - const clientRef = useRef(null) + const userRef = useRef(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 (
    - {/* Mobile menu */} + {/* Mobile menu panel */} {menuOpen && ( -
    -
      - {navLinks.map((link) => ( + diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index c674691..6cc4c03 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -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" />
      - +
      {/* Badge sobreposto à foto */}
      diff --git a/frontend/src/components/PropertyGridCard.tsx b/frontend/src/components/PropertyGridCard.tsx index 35000ed..651000f 100644 --- a/frontend/src/components/PropertyGridCard.tsx +++ b/frontend/src/components/PropertyGridCard.tsx @@ -61,19 +61,23 @@ export default function PropertyGridCard({ property }: { property: Property }) { aria-hidden="true" /> - {/* Badges */} -
      - {property.is_featured && ( + {/* Featured badge */} + {property.is_featured && ( +
      ⭐ Destaque - )} - {showNew && ( - +
      + )} + + {/* Novo — corner ribbon */} + {showNew && ( +
      +
      Novo - - )} -
      +
      +
      + )} {/* Listing type */}
      diff --git a/frontend/src/components/PropertyRowCard.tsx b/frontend/src/components/PropertyRowCard.tsx index 1f16816..4166b89 100644 --- a/frontend/src/components/PropertyRowCard.tsx +++ b/frontend/src/components/PropertyRowCard.tsx @@ -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 }) { {alt} 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 */} -
      - {isFeatured && ( + {isFeatured && ( +
      ⭐ Destaque - )} - {showNew && ( - +
      + )} + + {/* Novo — corner ribbon */} + {showNew && ( +
      +
      Novo - - )} -
      +
      +
      + )} {/* Prev / Next — visible on mobile, hover-only on desktop */} {slides.length > 1 && ( @@ -214,18 +220,12 @@ export default function PropertyRowCard({ property }: { property: Property }) {
      - {/* Subtype badge */} - {property.subtype && ( -
      - - {property.subtype.name} - -
      - )} - {/* Heart */}
      - +
      @@ -239,16 +239,23 @@ export default function PropertyRowCard({ property }: { property: Property }) { {/* ── Info (right) ─────────────────────────────────────────────── */}
      navigate(`/imoveis/${property.slug}`)}> - {/* Title + code */} + {/* Title + code + subtype */}

      {property.title}

      - {property.code && ( - - #{property.code} - - )} +
      + {property.subtype && ( + + {property.subtype.name} + + )} + {property.code && ( + + #{property.code} + + )} +
      @@ -270,14 +277,14 @@ export default function PropertyRowCard({ property }: { property: Property }) { /mês )}

      - {(property.condo_fee || property.iptu_anual) && ( + {(property.condo_fee != null || property.iptu_anual != null) && (
      - {property.condo_fee && parseFloat(property.condo_fee) > 0 && ( + {property.condo_fee != null && ( Cond. {formatPrice(property.condo_fee)}/mês )} - {property.iptu_anual && parseFloat(property.iptu_anual) > 0 && ( + {property.iptu_anual != null && ( IPTU {formatPrice(String(parseFloat(property.iptu_anual) / 12))}/mês diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index d433425..a8c07ad 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -10,6 +10,7 @@ interface AuthContextValue { login: (data: LoginCredentials) => Promise register: (data: RegisterCredentials) => Promise logout: () => void + updateUser: (partial: Partial) => void } const AuthContext = createContext(null) @@ -55,6 +56,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { window.location.href = '/login' }, []) + const updateUser = useCallback((partial: Partial) => { + setUser(prev => (prev ? { ...prev, ...partial } : prev)) + }, []) + return ( {children} diff --git a/frontend/src/contexts/FavoritesContext.tsx b/frontend/src/contexts/FavoritesContext.tsx index c47e58d..1a7e384 100644 --- a/frontend/src/contexts/FavoritesContext.tsx +++ b/frontend/src/contexts/FavoritesContext.tsx @@ -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; - toggle: (propertyId: string) => Promise; + localEntries: LocalFavoriteEntry[]; + toggle: (propertyId: string, snapshot?: LocalFavoriteEntry) => Promise; isLoading: boolean; } @@ -13,28 +36,72 @@ const FavoritesContext = createContext(null); export function FavoritesProvider({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth(); const [favoriteIds, setFavoriteIds] = useState>(new Set()); + const [localEntries, setLocalEntries] = useState([]); 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 ( - + {children} ); diff --git a/frontend/src/hooks/useInView.ts b/frontend/src/hooks/useInView.ts new file mode 100644 index 0000000..004cb75 --- /dev/null +++ b/frontend/src/hooks/useInView.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef, useState } from 'react' + +export function useInView(options?: IntersectionObserverInit) { + const ref = useRef(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 } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5c8cb85..ae81128 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); } +} diff --git a/frontend/src/layouts/ClientLayout.tsx b/frontend/src/layouts/ClientLayout.tsx index cabab01..e57befb 100644 --- a/frontend/src/layouts/ClientLayout.tsx +++ b/frontend/src/layouts/ClientLayout.tsx @@ -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 ( -
      - {/* Sidebar */} - - - {/* Main content */} -
      - {/* Mobile nav */} -
      -
      - {navItems.map(item => ( - - `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} - - ))} - {isAdmin && adminNavItems.map(item => ( - - `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} - - ))} -
      - -
      +
      diff --git a/frontend/src/pages/CadastroResidenciaPage.tsx b/frontend/src/pages/CadastroResidenciaPage.tsx new file mode 100644 index 0000000..5452550 --- /dev/null +++ b/frontend/src/pages/CadastroResidenciaPage.tsx @@ -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: ( + + + + ), + title: 'Atendimento qualificado', + desc: 'Corretores experientes e dedicados ao seu imóvel.', + }, + { + icon: ( + + + + ), + title: 'Maior visibilidade', + desc: 'Anúncios nos principais portais do mercado imobiliário.', + }, + { + icon: ( + + + + ), + title: 'Melhor negociação', + desc: 'Agilidade no processo e suporte completo até o fechamento.', + }, +] + +export default function CadastroResidenciaPage() { + const [form, setForm] = useState(INITIAL) + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState(null) + + function handleChange( + e: React.ChangeEvent + ) { + 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 ( + <> + +
      + + {/* Hero */} +
      +
      +

      + Quero anunciar +

      +

      + Ajudamos você a vender ou alugar seu imóvel com rapidez +

      +

      + Anuncie conosco e tenha acesso aos melhores portais do mercado imobiliário, + com atendimento especializado do início ao fechamento. +

      +
      + {STEPS.map((s) => ( +
      + + {s.num} + +
      +

      {s.title}

      +

      {s.desc}

      +
      +
      + ))} +
      +
      +
      + + {/* Benefícios */} +
      +

      + Benefícios que oferecemos para você +

      +
      + {BENEFICIOS.map((b) => ( +
      +
      + {b.icon} +
      +

      {b.title}

      +

      {b.desc}

      +
      + ))} +
      +
      + + {/* Formulário */} +
      +

      + Preencha o formulário abaixo e anuncie seu imóvel conosco! +

      +
      + {success ? ( +
      +
      + + + +
      +

      Cadastro enviado!

      +

      + Recebemos suas informações. Em breve um corretor especialista entrará em contato. +

      + +
      + ) : ( +
      + + {/* Dados pessoais */} +
      +

      + Dados Pessoais +

      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      + + {/* Dados do imóvel */} +
      +

      + Dados do Imóvel +

      +
      +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      + {[ + { name: 'aceita_permuta', label: 'Aceita permuta' }, + { name: 'aceita_financiamento', label: 'Aceita financiamento' }, + { name: 'ocupado', label: 'Imóvel ocupado' }, + ].map(({ name, label }) => ( + + ))} +
      +
      +
      + + {/* Endereço */} +
      +

      + Endereço do Imóvel +

      +
      +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      + + +
      +
      +
      + + {/* Observações */} +
      + +