feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s

- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
  preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
  keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint

migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
            i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
MatheusAlves96 2026-04-22 22:35:17 -03:00
parent 6ef5a7a17e
commit cf5603243c
106 changed files with 11927 additions and 1367 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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