feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

90
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,90 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import AdminRoute from './components/AdminRoute';
import ComparisonBar from './components/ComparisonBar';
import ProtectedRoute from './components/ProtectedRoute';
import { AuthProvider } from './contexts/AuthContext';
import { ComparisonProvider } from './contexts/ComparisonContext';
import { FavoritesProvider } from './contexts/FavoritesContext';
import AdminLayout from './layouts/AdminLayout';
import ClientLayout from './layouts/ClientLayout';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import PropertiesPage from './pages/PropertiesPage';
import PropertyDetailPage from './pages/PropertyDetailPage';
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 AdminAnalyticsPage from './pages/admin/AdminAnalyticsPage';
import AdminBoletosPage from './pages/admin/AdminBoletosPage';
import AdminCitiesPage from './pages/admin/AdminCitiesPage';
import AdminClientesPage from './pages/admin/AdminClientesPage';
import AdminFavoritosPage from './pages/admin/AdminFavoritosPage';
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 ComparisonPage from './pages/client/ComparisonPage';
import FavoritesPage from './pages/client/FavoritesPage';
import VisitsPage from './pages/client/VisitsPage';
export default function App() {
return (
<BrowserRouter>
<ComparisonProvider>
<AuthProvider>
<FavoritesProvider>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/imoveis" element={<PropertiesPage />} />
<Route path="/imoveis/:slug" element={<PropertyDetailPage />} />
<Route path="/corretores" element={<AgentsPage />} />
<Route path="/sobre" element={<AboutPage />} />
<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/cadastro" element={<RegisterPage />} />
<Route
path="/area-do-cliente"
element={
<ProtectedRoute>
<ClientLayout />
</ProtectedRoute>
}
>
<Route index element={<ClientDashboardPage />} />
<Route path="favoritos" element={<FavoritesPage />} />
<Route path="comparar" element={<ComparisonPage />} />
<Route path="visitas" element={<VisitsPage />} />
<Route path="boletos" element={<BoletosPage />} />
</Route>
<Route
path="/admin"
element={
<AdminRoute>
<AdminLayout />
</AdminRoute>
}
>
<Route index element={<AdminPropertiesPage />} />
<Route path="properties" element={<AdminPropertiesPage />} />
<Route path="clientes" element={<AdminClientesPage />} />
<Route path="boletos" element={<AdminBoletosPage />} />
<Route path="visitas" element={<AdminVisitasPage />} />
<Route path="favoritos" element={<AdminFavoritosPage />} />
<Route path="cidades" element={<AdminCitiesPage />} />
<Route path="amenidades" element={<AdminAmenitiesPage />} />
<Route path="corretores" element={<AdminAgentsPage />} />
<Route path="analytics" element={<AdminAnalyticsPage />} />
</Route>
</Routes>
<ComparisonBar />
</FavoritesProvider>
</AuthProvider>
</ComparisonProvider>
</BrowserRouter>
);
}

View file

@ -0,0 +1,55 @@
export default function AboutSection() {
return (
<section
id="sobre"
aria-labelledby="about-heading"
className="bg-panel-dark py-20 md:py-[80px] px-6"
>
<div className="max-w-[1200px] mx-auto">
<div className="max-w-[640px]">
<h2
id="about-heading"
className="text-2xl md:text-3xl font-medium text-textPrimary tracking-h2 mb-6"
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
>
Sobre Nós
</h2>
<p className="text-base text-textSecondary leading-relaxed mb-4">
A ImobiliáriaHub é uma plataforma dedicada a conectar compradores,
locatários e proprietários com as melhores oportunidades do mercado
imobiliário regional.
</p>
<p className="text-base text-textSecondary leading-relaxed mb-4">
Com mais de 10 anos de experiência, nossa equipe de corretores
especializados oferece atendimento personalizado para garantir que
você encontre o imóvel perfeito seja para morar, investir ou
alugar.
</p>
<p className="text-base text-textSecondary leading-relaxed">
Contamos com um portfólio exclusivo de mais de 200 imóveis
cuidadosamente selecionados em toda a região, desde studios
compactos até coberturas de alto padrão.
</p>
<div className="mt-10 grid grid-cols-3 gap-6 border-t border-borderSubtle pt-8">
<div>
<p className="text-2xl font-semibold text-textPrimary mb-1">200+</p>
<p className="text-sm text-textTertiary">Imóveis disponíveis</p>
</div>
<div>
<p className="text-2xl font-semibold text-textPrimary mb-1">10+</p>
<p className="text-sm text-textTertiary">Anos de mercado</p>
</div>
<div>
<p className="text-2xl font-semibold text-textPrimary mb-1">98%</p>
<p className="text-sm text-textTertiary">Clientes satisfeitos</p>
</div>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,201 @@
import type { PropertyFilters } from '../services/properties'
import type { City, Imobiliaria, Neighborhood, PropertyType } from '../types/catalog'
interface CatalogData {
propertyTypes: PropertyType[]
cities: City[]
neighborhoods: Neighborhood[]
imobiliarias: Imobiliaria[]
}
interface ActiveChip {
key: string
label: string
onRemove: () => void
}
interface ActiveFiltersBarProps {
filters: PropertyFilters
catalog: CatalogData
onFilterChange: (filters: PropertyFilters) => void
}
function omit<T extends object>(obj: T, keys: (keyof T)[]): T {
const result = { ...obj }
keys.forEach(k => delete result[k])
return result
}
export default function ActiveFiltersBar({ filters, catalog, onFilterChange }: ActiveFiltersBarProps) {
const chips: ActiveChip[] = []
// Busca textual (q)
if (filters.q?.trim()) {
chips.push({
key: 'q',
label: `"${filters.q.trim()}"`,
onRemove: () => onFilterChange({ ...filters, q: undefined, page: 1 }),
})
}
// Listing type
if (filters.listing_type) {
chips.push({
key: 'listing_type',
label: filters.listing_type === 'venda' ? 'Venda' : 'Aluguel',
onRemove: () => onFilterChange(omit(filters, ['listing_type'])),
})
}
// Imobiliária
if (filters.imobiliaria_id != null) {
const imob = catalog.imobiliarias.find(i => i.id === filters.imobiliaria_id)
chips.push({
key: 'imobiliaria_id',
label: imob ? imob.name : 'Imobiliária',
onRemove: () => onFilterChange(omit(filters, ['imobiliaria_id'])),
})
}
// City
if (filters.city_id != null) {
const city = catalog.cities.find(c => c.id === filters.city_id)
chips.push({
key: 'city_id',
label: city ? city.name : 'Cidade',
onRemove: () => onFilterChange({ ...omit(filters, ['city_id']), neighborhood_ids: undefined, page: 1 }),
})
}
// Neighborhoods (multi)
if (filters.neighborhood_ids?.length) {
filters.neighborhood_ids.forEach(id => {
const nb = catalog.neighborhoods.find(n => n.id === id)
chips.push({
key: `neighborhood_${id}`,
label: nb ? nb.name : 'Bairro',
onRemove: () => {
const next = (filters.neighborhood_ids ?? []).filter(x => x !== id)
onFilterChange({ ...filters, neighborhood_ids: next.length ? next : undefined, page: 1 })
},
})
})
}
// Subtypes (multi)
if (filters.subtype_ids?.length) {
const allSubtypes = catalog.propertyTypes.flatMap(t => t.subtypes ?? [])
filters.subtype_ids.forEach(id => {
const subtype = allSubtypes.find(s => s.id === id)
chips.push({
key: `subtype_${id}`,
label: subtype ? subtype.name : 'Tipo',
onRemove: () => {
const next = (filters.subtype_ids ?? []).filter(x => x !== id)
onFilterChange({ ...filters, subtype_ids: next.length ? next : undefined, page: 1 })
},
})
})
}
// Price
if (filters.price_min != null) {
chips.push({
key: 'price_min',
label: `A partir de ${new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(filters.price_min)}`,
onRemove: () => onFilterChange(omit(filters, ['price_min'])),
})
}
if (filters.price_max != null) {
chips.push({
key: 'price_max',
label: `Até ${new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(filters.price_max)}`,
onRemove: () => onFilterChange(omit(filters, ['price_max'])),
})
}
// Bedrooms
if (filters.bedrooms_min) {
chips.push({
key: 'bedrooms_min',
label: `${filters.bedrooms_min}+ quartos`,
onRemove: () => onFilterChange(omit(filters, ['bedrooms_min'])),
})
}
// Bathrooms
if (filters.bathrooms_min) {
chips.push({
key: 'bathrooms_min',
label: `${filters.bathrooms_min}+ banheiros`,
onRemove: () => onFilterChange(omit(filters, ['bathrooms_min'])),
})
}
// Parking
if (filters.parking_min) {
chips.push({
key: 'parking_min',
label: `${filters.parking_min}+ vagas`,
onRemove: () => onFilterChange(omit(filters, ['parking_min'])),
})
}
// Area
if (filters.area_min != null) {
chips.push({
key: 'area_min',
label: `A partir de ${filters.area_min}`,
onRemove: () => onFilterChange(omit(filters, ['area_min'])),
})
}
if (filters.area_max != null) {
chips.push({
key: 'area_max',
label: `Até ${filters.area_max}`,
onRemove: () => onFilterChange(omit(filters, ['area_max'])),
})
}
// Amenities
if (filters.amenity_ids?.length) {
chips.push({
key: 'amenity_ids',
label: `${filters.amenity_ids.length} comodidade${filters.amenity_ids.length !== 1 ? 's' : ''}`,
onRemove: () => onFilterChange(omit(filters, ['amenity_ids'])),
})
}
if (chips.length === 0) return null
return (
<div className="flex flex-wrap items-center gap-2 py-3">
{chips.map(chip => (
<span
key={chip.key}
className="inline-flex items-center gap-1.5 rounded-full bg-brand/10 border border-brand/20 text-xs font-medium text-brand px-2.5 py-1"
>
{chip.label}
<button
onClick={chip.onRemove}
aria-label={`Remover filtro ${chip.label}`}
className="text-brand/60 hover:text-brand transition-colors"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</span>
))}
{chips.length >= 2 && (
<button
onClick={() => onFilterChange({ page: 1, per_page: filters.per_page })}
className="text-xs text-textTertiary hover:text-textSecondary underline underline-offset-2 transition-colors"
>
Limpar tudo
</button>
)}
</div>
)
}

View file

@ -0,0 +1,21 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#5e6ad2] border-t-transparent" />
</div>
);
}
if (!isAuthenticated || user?.role !== 'admin') {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}

View file

@ -0,0 +1,80 @@
import type { Agent } from '../types/agent'
interface AgentCardProps {
agent: Agent
}
function getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join('')
}
export default function AgentCard({ agent }: AgentCardProps) {
return (
<div className="flex flex-col items-center gap-4 bg-panel border border-white/[0.06] rounded-2xl p-6 hover:border-white/[0.12] transition-colors">
{/* Avatar */}
<div className="relative flex-shrink-0">
{agent.photo_url ? (
<img
src={agent.photo_url}
alt={agent.name}
className="w-24 h-24 rounded-full object-cover ring-2 ring-white/10"
onError={(e) => {
const target = e.currentTarget
target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = 'flex'
}}
/>
) : null}
<div
className="w-24 h-24 rounded-full bg-[#5e6ad2]/20 ring-2 ring-[#5e6ad2]/30 items-center justify-center text-[#5e6ad2] text-xl font-semibold select-none"
style={{ display: agent.photo_url ? 'none' : 'flex' }}
>
{getInitials(agent.name)}
</div>
</div>
{/* Info */}
<div className="text-center w-full min-w-0">
<h3 className="text-textPrimary font-semibold text-base leading-snug truncate">
{agent.name}
</h3>
<p className="text-[#5e6ad2] text-xs font-medium mt-0.5">
CRECI: {agent.creci}
</p>
{agent.bio && (
<p className="text-textSecondary text-[13px] mt-2 leading-relaxed line-clamp-2">
{agent.bio}
</p>
)}
</div>
{/* Contact */}
<div className="w-full space-y-1.5 border-t border-white/[0.06] pt-4">
<a
href={`mailto:${agent.email}`}
className="flex items-center gap-2 text-textSecondary hover:text-textPrimary text-[13px] transition-colors truncate"
>
<svg className="w-3.5 h-3.5 flex-shrink-0 text-[#5e6ad2]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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 className="truncate">{agent.email}</span>
</a>
<a
href={`tel:${agent.phone}`}
className="flex items-center gap-2 text-textSecondary hover:text-textPrimary text-[13px] transition-colors"
>
<svg className="w-3.5 h-3.5 flex-shrink-0 text-[#5e6ad2]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>{agent.phone}</span>
</a>
</div>
</div>
)
}

View file

@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from 'react'
import { getAgents } from '../services/agents'
import type { Agent } from '../types/agent'
const AUTOPLAY_INTERVAL = 3500
const CARD_WIDTH = 220 // px, including gap
function getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join('')
}
function AgentSlide({ agent }: { agent: Agent }) {
return (
<div className="flex-shrink-0 w-[200px] flex flex-col items-center gap-3 bg-panel border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.14] transition-colors">
{/* Avatar */}
<div className="relative flex-shrink-0">
{agent.photo_url ? (
<img
src={agent.photo_url}
alt={agent.name}
className="w-20 h-20 rounded-full object-cover ring-2 ring-white/10"
onError={(e) => {
const target = e.currentTarget
target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = 'flex'
}}
/>
) : null}
<div
className="w-20 h-20 rounded-full bg-[#5e6ad2]/20 ring-2 ring-[#5e6ad2]/30 items-center justify-center text-[#5e6ad2] text-lg font-semibold select-none"
style={{ display: agent.photo_url ? 'none' : 'flex' }}
>
{getInitials(agent.name)}
</div>
</div>
{/* Info */}
<div className="text-center w-full min-w-0">
<p className="text-textPrimary font-semibold text-sm leading-snug truncate">
{agent.name}
</p>
<p className="text-[#5e6ad2] text-xs font-medium mt-0.5">
CRECI {agent.creci}
</p>
{agent.bio && (
<p className="text-textSecondary text-xs mt-2 line-clamp-2 leading-relaxed">
{agent.bio}
</p>
)}
</div>
{/* Phone */}
<a
href={`tel:${agent.phone.replace(/\D/g, '')}`}
className="mt-auto w-full text-center text-xs text-textSecondary hover:text-textPrimary transition-colors truncate"
>
{agent.phone}
</a>
</div>
)
}
function SkeletonSlide() {
return (
<div className="flex-shrink-0 w-[200px] flex flex-col items-center gap-3 bg-panel border border-white/[0.06] rounded-2xl p-5 animate-pulse">
<div className="w-20 h-20 rounded-full bg-white/[0.06]" />
<div className="w-full space-y-2">
<div className="h-3 bg-white/[0.06] rounded w-3/4 mx-auto" />
<div className="h-2 bg-white/[0.06] rounded w-1/2 mx-auto" />
</div>
</div>
)
}
export default function AgentsCarousel() {
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
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
const next = () => setCurrent((c) => (c + 1) % total)
const prev = () => setCurrent((c) => (c - 1 + total) % total)
useEffect(() => {
if (total === 0 || paused) return
autoplayRef.current = setInterval(next, AUTOPLAY_INTERVAL)
return () => {
if (autoplayRef.current) clearInterval(autoplayRef.current)
}
}, [total, paused, current])
// How many cards visible based on container width (handled via CSS, we translate by index)
const visibleCount = 4
const offset = current * CARD_WIDTH
if (loading) {
return (
<div className="flex gap-5 overflow-hidden px-1 py-2">
{Array.from({ length: 4 }).map((_, i) => <SkeletonSlide key={i} />)}
</div>
)
}
if (agents.length === 0) return null
return (
<div
className="relative overflow-hidden"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* Track */}
<div
ref={trackRef}
className="flex gap-5 transition-transform duration-500 ease-in-out py-2"
style={{ transform: `translateX(-${offset}px)` }}
>
{slides.map((agent, i) => (
<AgentSlide key={`${agent.id}-${i}`} agent={agent} />
))}
</div>
{/* Fade edges */}
<div className="pointer-events-none absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-canvas to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-canvas to-transparent" />
{/* Navigation dots */}
<div className="flex justify-center gap-1.5 mt-5">
{agents.map((_, i) => (
<button
key={i}
onClick={() => setCurrent(i)}
className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${
i === current % total
? 'bg-[#5e6ad2] w-4'
: 'bg-white/20 hover:bg-white/40'
}`}
aria-label={`Ir para corretor ${i + 1}`}
/>
))}
</div>
{/* Arrow buttons */}
<button
onClick={() => { prev(); setPaused(true) }}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-8 h-8 rounded-full bg-panel/80 border border-white/[0.08] flex items-center justify-center text-textSecondary hover:text-textPrimary hover:border-white/20 transition-all z-10"
aria-label="Anterior"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => { next(); setPaused(true) }}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1 w-8 h-8 rounded-full bg-panel/80 border border-white/[0.08] flex items-center justify-center text-textSecondary hover:text-textPrimary hover:border-white/20 transition-all z-10"
aria-label="Próximo"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)
}

View file

@ -0,0 +1,85 @@
export default function CTASection() {
return (
<section
id="contato"
aria-labelledby="cta-heading"
className="bg-surface-elevated border-t border-white/5 py-20 px-6"
>
<div className="max-w-[1200px] mx-auto text-center">
<h2
id="cta-heading"
className="text-2xl md:text-3xl font-medium text-textPrimary tracking-h2 mb-4"
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
>
Pronto para encontrar seu imóvel?
</h2>
<p className="text-base text-textSecondary leading-relaxed mb-8 max-w-[480px] mx-auto">
Nossa equipe está disponível para te ajudar a encontrar a melhor
opção. Entre em contato agora mesmo.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<a
href="tel:+5511999999999"
className="
inline-flex items-center justify-center gap-2
px-6 py-3
bg-brand-indigo hover:bg-accent-hover
text-white font-semibold text-sm
rounded transition-colors duration-200
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet focus-visible:ring-offset-2 focus-visible:ring-offset-surface-elevated
"
aria-label="Ligar para (11) 99999-9999"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12.5 19.79 19.79 0 0 1 1.61 3.87 2 2 0 0 1 3.58 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
(11) 99999-9999
</a>
<a
href="mailto:contato@imobiliariahub.com.br"
className="
inline-flex items-center justify-center gap-2
px-6 py-3
bg-white/5 hover:bg-white/[0.08]
text-textSecondary hover:text-textPrimary
font-semibold text-sm
rounded border border-white/10
transition-colors duration-200
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet focus-visible:ring-offset-2 focus-visible:ring-offset-surface-elevated
"
aria-label="Enviar e-mail para contato@imobiliariahub.com.br"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
Enviar e-mail
</a>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,65 @@
import { Link } from 'react-router-dom';
import { useComparison } from '../contexts/ComparisonContext';
export default function ComparisonBar() {
const { properties, remove, clear } = useComparison();
if (properties.length === 0) return null;
function formatPrice(price: number | string) {
const num = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(num);
}
return (
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-borderStandard bg-panel backdrop-blur-sm">
<div className="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
<span className="text-sm text-textSecondary shrink-0">
Comparando ({properties.length}/3):
</span>
<div className="flex flex-1 flex-wrap gap-2">
{properties.map(p => (
<div
key={p.id}
className="flex items-center gap-2 rounded-lg border border-borderStandard bg-surface px-3 py-1.5"
>
<span className="text-sm text-textPrimary line-clamp-1 max-w-[180px]">{p.title}</span>
<span className="text-xs text-textTertiary">{formatPrice(p.price)}</span>
<button
onClick={() => remove(p.id)}
aria-label="Remover da comparação"
className="text-textQuaternary hover:text-textPrimary transition ml-1"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
))}
{properties.length < 3 && (
<div className="flex items-center justify-center rounded-lg border border-dashed border-borderStandard px-6 py-1.5">
<span className="text-sm text-textQuaternary">Adicione mais um</span>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={clear}
className="text-sm text-textTertiary hover:text-textPrimary transition"
>
Limpar
</button>
<Link
to="/area-do-cliente/comparar"
className="rounded-lg bg-brand px-4 py-1.5 text-sm font-medium text-white hover:bg-accentHover transition"
>
Comparar
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getWhatsappNumber, submitContactForm } from '../services/contact'
// ── Types ─────────────────────────────────────────────────────────────────────
interface Props {
propertySlug: string
propertyCode: string | null
propertyTitle: string
onClose: () => void
}
type SubmitState = 'idle' | 'loading' | 'success' | 'error'
// ── Helpers ───────────────────────────────────────────────────────────────────
function defaultMessage(code: string | null, title: string): string {
const ref = code ? `Cód. ${code}` : `"${title}"`
return `Olá! Tenho interesse no imóvel ${ref} e gostaria de mais informações.`
}
// ── Subcomponents ─────────────────────────────────────────────────────────────
function CloseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
)
}
function WhatsAppIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z" />
<path d="M12 0C5.373 0 0 5.373 0 12c0 2.123.555 4.116 1.529 5.845L0 24l6.335-1.509A11.947 11.947 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 21.818a9.805 9.805 0 0 1-5.003-1.369l-.358-.213-3.762.896.953-3.658-.234-.375A9.802 9.802 0 0 1 2.182 12c0-5.424 4.394-9.818 9.818-9.818 5.424 0 9.818 4.394 9.818 9.818 0 5.424-4.394 9.818-9.818 9.818z" />
</svg>
)
}
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<label className="text-xs text-textSecondary font-medium">{label}</label>
{children}
{error && <span className="text-xs text-red-400">{error}</span>}
</div>
)
}
function inputCls(hasError: boolean) {
return `w-full rounded-lg bg-surface border ${hasError ? 'border-red-400/60' : 'border-borderPrimary'} px-3 py-2 text-sm text-textPrimary placeholder-textTertiary outline-none focus:border-emerald-500/60 transition-colors`
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export default function ContactModal({ propertySlug, propertyCode, propertyTitle, onClose }: Props) {
const overlayRef = useRef<HTMLDivElement>(null)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [message, setMessage] = useState(defaultMessage(propertyCode, propertyTitle))
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [errors, setErrors] = useState<Record<string, string>>({})
const [waUrl, setWaUrl] = useState('')
useEffect(() => {
getWhatsappNumber().then(number => {
if (!number) return
const clean = number.replace(/\D/g, '')
const msg = encodeURIComponent(defaultMessage(propertyCode, propertyTitle))
setWaUrl(`https://wa.me/${clean}?text=${msg}`)
})
}, [propertyCode, propertyTitle])
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
function handleOverlayClick(e: React.MouseEvent) {
if (e.target === overlayRef.current) onClose()
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setErrors({})
const errs: Record<string, string> = {}
if (name.trim().length < 2) errs.name = 'Nome deve ter pelo menos 2 caracteres.'
if (!email.includes('@')) errs.email = 'E-mail inválido.'
if (message.trim().length < 5) errs.message = 'Mensagem muito curta.'
if (Object.keys(errs).length) { setErrors(errs); return }
setSubmitState('loading')
try {
await submitContactForm(propertySlug, { name, email, phone: phone || undefined, message })
setSubmitState('success')
} catch {
setSubmitState('error')
}
}
return createPortal(
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div className="relative w-full max-w-md bg-panel border border-borderSubtle rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-borderSubtle">
<div>
<h2 className="text-sm font-semibold text-textPrimary">Entre em contato</h2>
{propertyCode && (
<p className="text-xs text-textTertiary mt-0.5">Cód. {propertyCode}</p>
)}
</div>
<button
onClick={onClose}
aria-label="Fechar"
className="w-7 h-7 rounded-full bg-surface hover:bg-surfaceSecondary text-textTertiary hover:text-textPrimary flex items-center justify-center transition-colors"
>
<CloseIcon />
</button>
</div>
{/* Body */}
<div className="px-5 py-4">
{submitState === 'success' ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center text-emerald-400 text-2xl"></div>
<p className="text-textPrimary font-medium">Mensagem enviada!</p>
<p className="text-sm text-textTertiary">Em breve entraremos em contato.</p>
<button onClick={onClose} className="mt-2 text-xs text-textTertiary hover:text-textPrimary underline">Fechar</button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<Field label="Nome *" error={errors.name}>
<input
value={name} onChange={e => setName(e.target.value)}
placeholder="Seu nome"
className={inputCls(!!errors.name)}
/>
</Field>
<Field label="E-mail *" error={errors.email}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="email@exemplo.com"
className={inputCls(!!errors.email)}
/>
</Field>
<Field label="Telefone / WhatsApp">
<input
type="tel" value={phone} onChange={e => setPhone(e.target.value)}
placeholder="(11) 99999-9999"
className={inputCls(false)}
/>
</Field>
<Field label="Mensagem *" error={errors.message}>
<textarea
value={message} onChange={e => setMessage(e.target.value)}
rows={3}
className={`${inputCls(!!errors.message)} resize-none`}
/>
</Field>
{submitState === 'error' && (
<p className="text-xs text-red-400">Erro ao enviar. Tente novamente.</p>
)}
<div className="flex flex-col gap-2 mt-1">
<button
type="submit"
disabled={submitState === 'loading'}
className="w-full rounded-xl bg-emerald-500 hover:bg-emerald-400 disabled:opacity-60 text-white font-semibold text-sm py-2.5 transition-colors"
>
{submitState === 'loading' ? 'Enviando…' : 'Enviar mensagem'}
</button>
{waUrl && (
<>
<div className="flex items-center gap-2 text-xs text-textTertiary">
<span className="flex-1 h-px bg-white/[0.08]" />
ou
<span className="flex-1 h-px bg-white/[0.08]" />
</div>
<a
href={waUrl}
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className="w-full flex items-center justify-center gap-2 rounded-xl border border-[#25D366]/40 bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#25D366] font-semibold text-sm py-2.5 transition-colors"
>
<WhatsAppIcon />
Falar pelo WhatsApp
</a>
</>
)}
</div>
</form>
)}
</div>
</div>
</div>,
document.body
)
}

View file

@ -0,0 +1,57 @@
export interface EmptyStateSuggestion {
label: string
count: number
onApply: () => void
}
interface EmptyStateWithSuggestionsProps {
hasFilters: boolean
suggestions: EmptyStateSuggestion[]
onClearAll: () => void
}
export default function EmptyStateWithSuggestions({
hasFilters,
suggestions,
onClearAll,
}: EmptyStateWithSuggestionsProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center px-4">
<div className="text-4xl mb-4">🏚</div>
<p className="text-textPrimary text-base font-semibold mb-1">
Nenhum imóvel encontrado
</p>
<p className="text-textTertiary text-sm mb-6">
{hasFilters
? 'Tente relaxar alguns filtros para ampliar os resultados.'
: 'Não há imóveis disponíveis no momento.'}
</p>
{suggestions.length > 0 && (
<div className="w-full max-w-sm flex flex-col gap-2 mb-6">
{suggestions.map((s, i) => (
<button
key={i}
onClick={s.onApply}
className="flex items-center justify-between w-full rounded-xl border border-borderSubtle bg-surface hover:border-brand/30 hover:bg-brand/5 transition-all px-4 py-3 text-left"
>
<span className="text-sm text-textSecondary">{s.label}</span>
<span className="text-xs font-semibold text-brand ml-3 shrink-0">
{s.count} imóv{s.count !== 1 ? 'eis' : 'el'}
</span>
</button>
))}
</div>
)}
{hasFilters && (
<button
onClick={onClearAll}
className="text-sm text-accent-violet hover:underline"
>
Limpar todos os filtros
</button>
)}
</div>
)
}

View file

@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { getFeaturedProperties } from '../services/properties'
import type { Property } from '../types/property'
import PropertyCard from './PropertyCard'
import PropertyCardSkeleton from './PropertyCardSkeleton'
type FetchState = 'loading' | 'success' | 'error'
export default function FeaturedProperties() {
const [properties, setProperties] = useState<Property[]>([])
const [state, setState] = useState<FetchState>('loading')
useEffect(() => {
getFeaturedProperties()
.then((data) => {
setProperties(data)
setState('success')
})
.catch(() => {
setState('error')
})
}, [])
return (
<section
id="imoveis"
aria-labelledby="featured-heading"
className="py-20 px-6"
>
<div className="max-w-[1200px] mx-auto">
<div className="mb-10">
<h2
id="featured-heading"
className="text-2xl md:text-3xl font-medium text-textPrimary tracking-h2"
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
>
Imóveis em Destaque
</h2>
<p className="mt-2 text-textSecondary text-base">
Selecionados especialmente para você
</p>
</div>
{state === 'loading' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }).map((_, i) => (
<PropertyCardSkeleton key={i} />
))}
</div>
)}
{state === 'success' && properties.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{properties.map((property) => (
<PropertyCard key={property.id} property={property} />
))}
</div>
)}
{state === 'success' && properties.length === 0 && (
<div className="flex items-center justify-center py-16">
<p className="text-textTertiary text-base text-center">
Nenhum imóvel em destaque no momento.
<br />
<span className="text-sm">Volte em breve para novas oportunidades.</span>
</p>
</div>
)}
{state === 'error' && (
<div className="flex items-center justify-center py-16">
<p className="text-textTertiary text-base text-center">
Não foi possível carregar os imóveis no momento.
<br />
<span className="text-sm">Tente novamente mais tarde.</span>
</p>
</div>
)}
</div>
</section>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
const footerLinks = [
{ label: 'Imóveis', href: '/imoveis' },
{ label: 'Sobre', href: '/sobre' },
{ label: 'Contato', href: '#contato' },
{ label: 'Política de Privacidade', href: '/politica-de-privacidade' },
]
const currentYear = new Date().getFullYear()
export default function Footer() {
return (
<footer
role="contentinfo"
className="bg-panel border-t border-borderSubtle py-10 px-6"
>
<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">
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.
</p>
</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>
{/* 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 className="mt-8 pt-6 border-t border-borderSubtle">
<p className="text-xs text-textQuaternary text-center">
© {currentYear} ImobiliáriaHub. Todos os direitos reservados.
</p>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,51 @@
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useFavorites } from '../contexts/FavoritesContext';
interface HeartButtonProps {
propertyId: string;
className?: string;
}
export default function HeartButton({ propertyId, className = '' }: HeartButtonProps) {
const { isAuthenticated } = useAuth();
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);
}
return (
<button
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'
} ${className}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={isFav ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
className="h-5 w-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>
</button>
);
}

View file

@ -0,0 +1,103 @@
interface HeroSectionProps {
headline: string
subheadline: string | null
ctaLabel: string
ctaUrl: string
isLoading?: boolean
backgroundImage?: string | null
}
export default function HeroSection({
headline,
subheadline,
ctaLabel,
ctaUrl,
isLoading = false,
backgroundImage,
}: HeroSectionProps) {
if (isLoading) {
return (
<section
aria-label="Carregando hero"
className="relative min-h-[600px] flex items-center justify-center pt-14"
style={{
background:
'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(94,106,210,0.08) 0%, transparent 60%), #08090a',
}}
>
<div className="w-full max-w-[800px] mx-auto px-6 text-center animate-pulse">
{/* Headline skeleton */}
<div className="h-16 md:h-20 lg:h-24 bg-surface-secondary rounded-lg w-3/4 mx-auto mb-6" />
{/* Subheadline skeleton */}
<div className="h-6 bg-surface-secondary rounded w-1/2 mx-auto mb-3" />
<div className="h-6 bg-surface-secondary rounded w-2/5 mx-auto mb-10" />
{/* CTA skeleton */}
<div className="h-11 bg-surface-secondary rounded w-36 mx-auto" />
</div>
</section>
)
}
return (
<section
aria-label="Hero principal"
className="relative min-h-[600px] flex items-center justify-center pt-14 overflow-hidden"
style={!backgroundImage ? {
background:
'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(94,106,210,0.08) 0%, transparent 60%), #08090a',
} : undefined}
>
{/* Imagem de fundo */}
{backgroundImage && (
<>
<img
src={backgroundImage}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
{/* Overlay escuro para legibilidade */}
<div
className="absolute inset-0"
style={{ background: 'linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.65) 100%)' }}
/>
</>
)}
<div className="w-full max-w-[800px] mx-auto px-6 text-center py-20 md:py-28">
<h1
className="
text-[40px] md:text-[48px] lg:text-[72px]
font-medium text-textPrimary leading-tight
tracking-display-xl
mb-6
"
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
>
{headline}
</h1>
{subheadline && (
<p className="text-lg md:text-xl text-textSecondary font-light leading-relaxed mb-10 max-w-[560px] mx-auto">
{subheadline}
</p>
)}
<a
href={ctaUrl}
className="
inline-flex items-center justify-center
px-6 py-3
bg-brand-indigo hover:bg-accent-hover
text-white font-semibold text-sm
rounded transition-colors duration-200
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet focus-visible:ring-offset-2 focus-visible:ring-offset-mkt-black
"
aria-label={ctaLabel}
>
{ctaLabel}
</a>
</div>
</section>
)
}

View file

@ -0,0 +1,257 @@
import { useEffect, useRef, useState } from 'react'
import PropertyRowCard from './PropertyRowCard'
import { getFeaturedProperties } from '../services/properties'
import type { Property } from '../types/property'
// ── 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()
}, [])
return (
<div
ref={ref}
style={{ transitionDelay: `${Math.min(index * 60, 240)}ms` }}
className={`transition-all duration-700 ease-out ${visible
? 'opacity-100 translate-y-0'
: 'opacity-0 translate-y-12'
}`}
>
{children}
</div>
)
}
// ── Skeleton de card em linha ─────────────────────────────────────────────────
function RowSkeleton() {
return (
<div className="flex h-[220px] bg-panel border border-white/5 rounded-2xl overflow-hidden animate-pulse">
<div className="flex-shrink-0 w-[340px] h-full bg-white/[0.06]" />
<div className="flex flex-col flex-1 p-5 gap-3">
<div className="h-4 bg-white/[0.06] rounded w-3/4" />
<div className="h-3 bg-white/[0.06] rounded w-1/3" />
<div className="h-5 bg-white/[0.06] rounded w-1/2" />
<div className="flex gap-3 mt-1">
<div className="h-3 bg-white/[0.06] rounded w-12" />
<div className="h-3 bg-white/[0.06] rounded w-12" />
<div className="h-3 bg-white/[0.06] rounded w-14" />
</div>
<div className="mt-auto h-7 bg-white/[0.06] rounded w-24" />
</div>
</div>
)
}
// ── Scroll hint (seta animada) ────────────────────────────────────────────────
function ScrollHint({ label }: { label: string }) {
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>
<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"
style={{ animation: `fadeDown 1.4s ease-in-out ${i * 0.2}s infinite` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
</svg>
))}
</div>
</div>
)
}
// ── Componente principal ──────────────────────────────────────────────────────
interface HomeScrollSceneProps {
headline: string
subheadline: string | null
ctaLabel: string
ctaUrl: string
backgroundImage?: string | null
isLoading?: boolean
}
export default function HomeScrollScene({
headline,
subheadline,
ctaLabel,
ctaUrl,
backgroundImage,
isLoading = false,
}: HomeScrollSceneProps) {
const [properties, setProperties] = useState<Property[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
getFeaturedProperties()
.then(setProperties)
.catch(() => { })
.finally(() => setLoading(false))
}, [])
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">
{backgroundImage ? (
<img
src={backgroundImage}
alt=""
aria-hidden="true"
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(','),
}}
/>
)}
{/* Sobreposição de gradiente — suaviza edges */}
<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%)',
}}
/>
{/* ── Hero text centralizado sobre a imagem ─────────────────── */}
<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>
) : (
<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)]"
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)]">
{subheadline}
</p>
)}
<a
href={ctaUrl || '/imoveis'}
className="inline-flex items-center gap-2 mt-8 px-7 py-3 rounded-full bg-[#5e6ad2] hover:bg-[#6e7ae0] text-white text-sm font-medium transition-colors shadow-lg shadow-[#5e6ad2]/30"
>
{ctaLabel}
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
)}
</div>
{/* Indicador de rolar */}
<ScrollHint label="Imóveis em destaque" />
</div>
{/* ── Seção de imóveis que sobe sobre a imagem ─────────────────── */}
<div className="relative z-10">
{/* Fade de transição */}
<div
className="h-48 pointer-events-none"
style={{
background: 'linear-gradient(to bottom, transparent 0%, #08090a 100%)',
}}
/>
<div
className="pb-40"
style={{ background: '#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"
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
>
Imóveis em Destaque
</h2>
<p className="mt-1.5 text-textSecondary text-sm">
Selecionados especialmente para você
</p>
</div>
{/* Cards */}
<div className="max-w-[980px] mx-auto px-6 flex flex-col gap-4">
{loading
? Array.from({ length: 3 }).map((_, i) => <RowSkeleton key={i} />)
: properties.map((p, i) => (
<RiseCard key={p.id} index={i}>
<PropertyRowCard property={p} />
</RiseCard>
))
}
</div>
{/* CTA direto para /imoveis */}
{!loading && (
<div className="max-w-[980px] mx-auto px-6 mt-16 flex flex-col items-center gap-4">
<a
href="/imoveis"
className="inline-flex items-center gap-2 px-8 py-3.5 rounded-full bg-[#5e6ad2] hover:bg-[#6e7ae0] text-white text-sm font-medium transition-colors shadow-lg shadow-[#5e6ad2]/25"
>
Ver todos os imóveis
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
)}
</div>
</div>
</div>
</>
)
}

View file

@ -0,0 +1,294 @@
import { useEffect, useRef, useState } from 'react'
import { Link, NavLink } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
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 },
]
const adminNavItems = [
{ to: '/admin/properties', label: 'Imóveis' },
{ to: '/admin/corretores', label: 'Corretores' },
{ to: '/admin/clientes', label: 'Clientes' },
{ to: '/admin/boletos', label: 'Boletos' },
{ to: '/admin/visitas', label: 'Visitas' },
{ to: '/admin/favoritos', label: 'Favoritos' },
{ to: '/admin/cidades', label: 'Cidades' },
{ to: '/admin/amenidades', label: 'Amenidades' },
{ to: '/admin/analytics', label: 'Analytics' },
]
const clientNavItems = [
{ to: '/area-do-cliente', label: 'Painel', end: true },
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false },
{ 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 },
]
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 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]'
}`
export default function Navbar() {
const [menuOpen, setMenuOpen] = useState(false)
const [adminOpen, setAdminOpen] = useState(false)
const [clientOpen, setClientOpen] = useState(false)
const { user, isAuthenticated, isLoading, logout } = useAuth()
const isAdmin = isAuthenticated && user && user.role === 'admin'
const adminRef = useRef<HTMLDivElement>(null)
const clientRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (adminRef.current && !adminRef.current.contains(e.target as Node)) {
setAdminOpen(false)
}
if (clientRef.current && !clientRef.current.contains(e.target as Node)) {
setClientOpen(false)
}
}
document.addEventListener('mousedown', handleOutside)
return () => document.removeEventListener('mousedown', handleOutside)
}, [])
return (
<header
role="banner"
className="fixed top-0 left-0 right-0 z-50 border-b border-borderSubtle"
style={{ background: 'var(--navbar-bg)', backdropFilter: 'blur(12px)' }}
>
<nav
aria-label="Navegação principal"
className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between"
>
{/* Logo */}
<Link
to="/"
className="flex items-center gap-2 text-textPrimary font-semibold text-base tracking-tight hover:opacity-80 transition-opacity"
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>
</Link>
{/* Desktop nav */}
<ul className="hidden md:flex items-center gap-6 list-none m-0 p-0">
{navLinks.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>
)}
</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>
</ul>
{/* Desktop auth (apenas não-autenticado) */}
<div className="hidden md:flex items-center gap-3">
{isLoading ? (
<div 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]"
>
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}
</div>
{/* Mobile hamburger */}
<div className="md:hidden flex items-center gap-2">
<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'}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen(prev => !prev)}
>
<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' : ''}`} />
</button>
</div>
</nav>
{/* Mobile menu */}
{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) => (
<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>
)}
</li>
))}
{/* Mobile admin items */}
{isAdmin && (
<>
<li className="pt-2 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)}>
{item.label}
</NavLink>
</li>
))}
</>
)}
{/* Mobile client items */}
{isAuthenticated && user && !isAdmin && (
<>
<li className="pt-2 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)}>
{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">
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>
</li>
)
)}
</ul>
</div>
)}
</header>
)
}

View file

@ -0,0 +1,238 @@
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';
interface PropertyCardProps {
property: Property
}
function formatPrice(price: string): string {
const num = parseFloat(price)
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(num)
}
function BedIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M2 9V4a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v5" />
<path d="M2 9h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z" />
<path d="M6 9v4" />
</svg>
)
}
function BathIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 6 6.5 3.5a1.5 1.5 0 0 0-1-.5C4.683 3 4 3.683 4 4.5V17a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5" />
<line x1="10" x2="8" y1="5" y2="7" />
<line x1="2" x2="22" y1="12" y2="12" />
<line x1="7" x2="7" y1="19" y2="21" />
<line x1="17" x2="17" y1="19" y2="21" />
</svg>
)
}
function AreaIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
)
}
function CarIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 17H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1l2-3h10l2 3h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-2" />
<circle cx="7" cy="17" r="2" />
<circle cx="17" cy="17" r="2" />
</svg>
)
}
export default function PropertyCard({ property }: PropertyCardProps) {
const photoUrl = property.photos.length > 0 ? property.photos[0].url : '/placeholder-property.jpg';
const photoAlt = property.photos.length > 0 ? property.photos[0].alt_text : property.title;
const isVenda = property.type === 'venda';
const { isInComparison, add, remove } = useComparison();
const inComparison = isInComparison(property.id);
const [contactOpen, setContactOpen] = useState(false);
function handleCompareClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (inComparison) remove(property.id);
else add(property);
}
function handleContactClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setContactOpen(true);
}
return (
<div className="relative h-full">
<Link
to={`/imoveis/${property.slug}`}
className="group bg-panel border border-borderSubtle rounded-xl overflow-hidden hover:border-borderStandard transition-all duration-200 flex flex-col h-full"
aria-label={`Ver detalhes: ${property.title}`}
>
{/* Photo */}
<div className="aspect-[4/3] w-full overflow-hidden relative flex-shrink-0">
<img
src={photoUrl}
alt={photoAlt}
className="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300"
loading="lazy"
/>
<div className="absolute top-2 right-2 z-10">
<HeartButton propertyId={property.id} />
</div>
{/* Badge sobreposto à foto */}
<div className="absolute bottom-2 left-2">
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5 backdrop-blur-sm ${isVenda
? 'bg-brand/80 text-white'
: 'bg-black/50 text-white/90 border border-white/20'
}`}
>
{isVenda ? 'Venda' : 'Aluguel'}
</span>
</div>
</div>
{/* Content — flex-col flex-1 para empurrar footer para baixo */}
<div className="p-4 flex flex-col flex-1">
{/* Title */}
<h3 className="text-sm font-medium text-textPrimary leading-snug mb-1 line-clamp-2">
{property.title}
</h3>
{/* Location */}
{(property.city || property.neighborhood) && (
<p className="text-xs text-textTertiary mb-2 truncate">
{[property.neighborhood?.name, property.city?.name]
.filter(Boolean)
.join(', ')}
</p>
)}
{/* Price */}
<p className="text-base font-semibold text-textPrimary mb-3">
{formatPrice(property.price)}
{!isVenda && (
<span className="text-xs text-textTertiary font-normal ml-1">/mês</span>
)}
</p>
{/* Footer — empurrado para o fundo */}
<div className="mt-auto space-y-3">
{/* Stats */}
<div className="flex items-center gap-3 text-xs text-textSecondary flex-wrap">
<span className="flex items-center gap-1">
<BedIcon />
{property.bedrooms} qts
</span>
<span className="flex items-center gap-1">
<BathIcon />
{property.bathrooms} ban
</span>
<span className="flex items-center gap-1">
<AreaIcon />
{property.area_m2} m²
</span>
{property.parking_spots > 0 && (
<span className="flex items-center gap-1">
<CarIcon />
{property.parking_spots} vaga{property.parking_spots !== 1 ? 's' : ''}
</span>
)}
</div>
{/* Compare button */}
<button
onClick={handleCompareClick}
className={`w-full rounded-lg px-3 py-1.5 text-xs font-medium transition border border-borderStandard ${inComparison
? 'bg-brand text-white hover:bg-accentHover'
: 'bg-surface text-textSecondary hover:bg-surfaceSecondary hover:text-textPrimary'
}`}
>
{inComparison ? '✓ Comparando' : 'Comparar'}
</button>
{/* Contact button */}
<button
onClick={handleContactClick}
className="w-full rounded-xl bg-emerald-500 hover:bg-emerald-400 text-white font-semibold text-xs py-2 transition-colors"
>
Entre em contato
</button>
</div>
</div>
</Link>
{contactOpen && (
<ContactModal
propertySlug={property.slug}
propertyCode={property.code}
propertyTitle={property.title}
onClose={() => setContactOpen(false)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
export default function PropertyCardSkeleton() {
return (
<div className="bg-surface-elevated border border-white/5 rounded-xl overflow-hidden animate-pulse flex flex-col h-full">
{/* Photo skeleton — aspect-[4/3] para corresponder ao card */}
<div className="aspect-[4/3] w-full bg-surface-secondary flex-shrink-0" />
{/* Content skeleton */}
<div className="p-4 flex flex-col flex-1">
{/* Title skeleton */}
<div className="mb-1 space-y-1.5">
<div className="h-4 bg-surface-secondary rounded w-full" />
<div className="h-4 bg-surface-secondary rounded w-3/4" />
</div>
{/* Location skeleton */}
<div className="h-3 bg-surface-secondary rounded w-2/5 mb-2" />
{/* Price skeleton */}
<div className="h-5 bg-surface-secondary rounded w-2/5 mb-3" />
{/* Footer */}
<div className="mt-auto space-y-3">
{/* Stats skeleton */}
<div className="flex items-center gap-3">
<div className="h-3.5 bg-surface-secondary rounded w-10" />
<div className="h-3.5 bg-surface-secondary rounded w-10" />
<div className="h-3.5 bg-surface-secondary rounded w-12" />
<div className="h-3.5 bg-surface-secondary rounded w-10" />
</div>
{/* Compare button skeleton */}
<div className="h-7 bg-surface-secondary rounded w-full" />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,52 @@
import type { Amenity } from '../../types/catalog'
interface AmenitiesSectionProps {
amenities: Amenity[]
}
const GROUP_LABELS: Record<string, string> = {
caracteristica: 'Características',
lazer: 'Lazer',
condominio: 'Condomínio',
seguranca: 'Segurança',
}
const GROUP_ORDER = ['caracteristica', 'lazer', 'condominio', 'seguranca']
function CheckIcon() {
return (
<svg width="9" height="7" viewBox="0 0 9 7" fill="none" aria-hidden="true">
<path d="M1 3.5L3.5 6L8 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export default function AmenitiesSection({ amenities }: AmenitiesSectionProps) {
if (amenities.length === 0) return null
return (
<div className="space-y-6">
{GROUP_ORDER.map((group) => {
const items = amenities.filter((a) => a.group === group)
if (items.length === 0) return null
return (
<div key={group}>
<h3 className="text-xs font-medium text-textQuaternary uppercase tracking-[0.08em] mb-3">
{GROUP_LABELS[group]}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
{items.map((amenity) => (
<div key={amenity.id} className="flex items-center gap-2.5">
<span className="w-4 h-4 rounded flex-shrink-0 flex items-center justify-center bg-brand-indigo border border-brand-indigo">
<CheckIcon />
</span>
<span className="text-sm text-textSecondary">{amenity.name}</span>
</div>
))}
</div>
</div>
)
})}
</div>
)
}

View file

@ -0,0 +1,175 @@
import { useState } from 'react'
import { submitContactForm } from '../../services/properties'
import type { ContactFormData } from '../../types/property'
interface ContactSectionProps {
slug: string
propertyTitle: string
propertyCode: string | null
}
function WhatsAppIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z" />
</svg>
)
}
function SpinnerIcon() {
return (
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)
}
const EMPTY_FORM: ContactFormData = { name: '', email: '', phone: '', message: '' }
export default function ContactSection({ slug, propertyTitle, propertyCode }: ContactSectionProps) {
const [form, setForm] = useState<ContactFormData>(EMPTY_FORM)
const [errors, setErrors] = useState<Partial<ContactFormData>>({})
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [serverError, setServerError] = useState(false)
const waNumber = import.meta.env.VITE_WHATSAPP_NUMBER as string | undefined
const waText = encodeURIComponent(
`Olá! Tenho interesse no imóvel${propertyCode ? ` Cod. ${propertyCode}` : ''}: ${propertyTitle}\n${window.location.href}`
)
function validate(): boolean {
const errs: Partial<ContactFormData> = {}
if (!form.name.trim() || form.name.trim().length < 2) errs.name = 'Informe seu nome completo.'
const emailRe = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
if (!emailRe.test(form.email.trim())) errs.email = 'E-mail inválido.'
if (!form.message.trim() || form.message.trim().length < 10) errs.message = 'Mensagem deve ter pelo menos 10 caracteres.'
setErrors(errs)
return Object.keys(errs).length === 0
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!validate()) return
setSubmitting(true)
setServerError(false)
try {
await submitContactForm(slug, form)
setSuccess(true)
setForm(EMPTY_FORM)
setErrors({})
} catch {
setServerError(true)
} finally {
setSubmitting(false)
}
}
function handleChange(field: keyof ContactFormData, value: string) {
setForm((f) => ({ ...f, [field]: value }))
if (errors[field]) setErrors((e) => ({ ...e, [field]: undefined }))
}
const inputBase = 'w-full bg-surface border border-borderPrimary rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/30 transition-colors'
const inputError = 'border-red-500/50 focus:border-red-500/70'
return (
<div className="space-y-5">
{/* WhatsApp */}
{waNumber && (
<a
href={`https://wa.me/${waNumber}?text=${waText}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2.5 w-full py-3 rounded-xl bg-[#25D366] hover:bg-[#22c55e] text-white font-medium text-sm transition-colors"
>
<WhatsAppIcon />
Chamar no WhatsApp
</a>
)}
{/* Divider */}
<div className="flex items-center gap-3 text-textQuaternary text-xs">
<div className="flex-1 h-px bg-borderSubtle" />
ou preencha o formulário
<div className="flex-1 h-px bg-borderSubtle" />
</div>
{/* Form */}
{success ? (
<div className="bg-brand-indigo/10 border border-brand-indigo/20 rounded-xl p-5 text-center">
<p className="text-textPrimary font-medium mb-1">Mensagem enviada com sucesso!</p>
<p className="text-sm text-textTertiary">Entraremos em contato em breve.</p>
<button
onClick={() => setSuccess(false)}
className="mt-4 text-xs text-accent-violet hover:underline"
>
Enviar outra mensagem
</button>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="space-y-3">
<div>
<input
type="text"
placeholder="Seu nome *"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
className={`${inputBase} ${errors.name ? inputError : ''}`}
/>
{errors.name && <p className="text-xs text-red-400 mt-1">{errors.name}</p>}
</div>
<div>
<input
type="email"
placeholder="Seu e-mail *"
value={form.email}
onChange={(e) => handleChange('email', e.target.value)}
className={`${inputBase} ${errors.email ? inputError : ''}`}
/>
{errors.email && <p className="text-xs text-red-400 mt-1">{errors.email}</p>}
</div>
<div>
<input
type="tel"
placeholder="Telefone (opcional)"
value={form.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className={inputBase}
/>
</div>
<div>
<textarea
placeholder="Sua mensagem *"
rows={4}
value={form.message}
onChange={(e) => handleChange('message', e.target.value)}
className={`${inputBase} resize-none ${errors.message ? inputError : ''}`}
/>
{errors.message && <p className="text-xs text-red-400 mt-1">{errors.message}</p>}
</div>
{serverError && (
<p className="text-sm text-red-400">Erro ao enviar. Tente novamente mais tarde.</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-brand-indigo hover:bg-brand-indigo/90 text-white font-medium text-sm transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<SpinnerIcon />
Enviando
</>
) : (
'Enviar mensagem'
)}
</button>
</form>
)}
</div>
)
}

View file

@ -0,0 +1,139 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PropertyPhoto } from '../../types/property'
interface PhotoCarouselProps {
photos: PropertyPhoto[]
}
function ChevronLeft() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
)
}
function ChevronRight() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function NoPhotoPlaceholder() {
return (
<div className="w-full aspect-[16/9] bg-panel border border-white/5 rounded-xl flex flex-col items-center justify-center gap-3 text-textQuaternary">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="3" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span className="text-sm">Sem fotos disponíveis</span>
</div>
)
}
export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
const [activeIndex, setActiveIndex] = useState(0)
const touchStartX = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const prev = useCallback(() => {
setActiveIndex((i) => (i === 0 ? photos.length - 1 : i - 1))
}, [photos.length])
const next = useCallback(() => {
setActiveIndex((i) => (i === photos.length - 1 ? 0 : i + 1))
}, [photos.length])
useEffect(() => {
const el = containerRef.current
if (!el) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') prev()
else if (e.key === 'ArrowRight') next()
}
el.addEventListener('keydown', handleKey)
return () => el.removeEventListener('keydown', handleKey)
}, [prev, next])
if (photos.length === 0) return <NoPhotoPlaceholder />
const active = photos[activeIndex]
const single = photos.length === 1
return (
<div ref={containerRef} className="w-full outline-none" tabIndex={0}>
{/* Main photo */}
<div className="relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel">
<img
src={active.url}
alt={active.alt_text || `Foto ${activeIndex + 1}`}
className="w-full h-full object-cover transition-opacity duration-300"
loading="lazy"
/>
{/* Nav buttons */}
{!single && (
<>
<button
onClick={prev}
aria-label="Foto anterior"
className="absolute left-3 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-black/50 backdrop-blur-sm border border-white/10 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
>
<ChevronLeft />
</button>
<button
onClick={next}
aria-label="Próxima foto"
className="absolute right-3 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full bg-black/50 backdrop-blur-sm border border-white/10 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
>
<ChevronRight />
</button>
{/* Counter */}
<div className="absolute bottom-3 right-3 bg-black/60 backdrop-blur-sm rounded-full px-2.5 py-1 text-xs text-white/80">
{activeIndex + 1} / {photos.length}
</div>
</>
)}
</div>
{/* Thumbnail strip */}
{!single && (
<div
className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-thin"
onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX }}
onTouchEnd={(e) => {
if (touchStartX.current === null) return
const delta = e.changedTouches[0].clientX - touchStartX.current
if (delta < -50) next()
else if (delta > 50) prev()
touchStartX.current = null
}}
>
{photos.map((photo, idx) => (
<button
key={idx}
onClick={() => setActiveIndex(idx)}
aria-label={`Ver foto ${idx + 1}`}
aria-current={idx === activeIndex}
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 ${idx === activeIndex
? 'border-accent-violet opacity-100'
: 'border-transparent opacity-50 hover:opacity-75'
}`}
>
<img
src={photo.url}
alt={photo.alt_text || `Miniatura ${idx + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,53 @@
interface PriceBoxProps {
price: string
condo_fee: string | null
listing_type: 'venda' | 'aluguel'
}
function formatBRL(value: string): string {
const num = parseFloat(value)
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
minimumFractionDigits: 2,
}).format(num)
}
export default function PriceBox({ price, condo_fee, listing_type }: PriceBoxProps) {
const isVenda = listing_type === 'venda'
return (
<div className="bg-panel border border-white/5 rounded-xl p-5 lg:sticky lg:top-6">
{/* Badge */}
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-2.5 py-1 mb-4 ${isVenda
? 'bg-brand-indigo/20 text-accent-violet'
: 'bg-white/5 text-textMuted border border-white/10'
}`}
>
{isVenda ? 'Venda' : 'Aluguel'}
</span>
{/* Price */}
<p className="text-3xl font-semibold text-textPrimary tracking-tight leading-tight mb-1">
{formatBRL(price)}
</p>
{!isVenda && (
<p className="text-xs text-textQuaternary mb-3">/mês</p>
)}
{/* Condo fee */}
{condo_fee != null && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-white/5">
<span className="text-sm text-textTertiary">Condomínio</span>
<span className="text-sm text-textSecondary">{formatBRL(condo_fee)}/mês</span>
</div>
)}
{/* Note */}
<p className="text-xs text-textQuaternary mt-4 leading-relaxed">
Valores sujeitos a alteração sem aviso prévio.
</p>
</div>
)
}

View file

@ -0,0 +1,82 @@
export default function PropertyDetailSkeleton() {
return (
<div className="animate-pulse">
{/* Photo carousel placeholder */}
<div className="w-full aspect-[16/9] bg-panel rounded-xl mb-3" />
{/* Thumbnails */}
<div className="flex gap-2 mb-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="w-20 h-14 bg-panel rounded-lg flex-shrink-0" />
))}
</div>
<div className="flex flex-col lg:flex-row gap-8">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
{/* Breadcrumb */}
<div className="flex gap-2">
{[60, 80, 100, 140].map((w, i) => (
<div key={i} className="h-3 bg-panel rounded" style={{ width: w }} />
))}
</div>
{/* Title */}
<div className="space-y-2">
<div className="h-7 bg-panel rounded w-3/4" />
<div className="h-4 bg-panel rounded w-1/3" />
</div>
{/* Stats strip */}
<div className="bg-panel border border-white/5 rounded-xl flex divide-x divide-white/5">
{[...Array(4)].map((_, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1.5 px-4 py-3">
<div className="w-5 h-5 bg-surface-elevated rounded" />
<div className="w-8 h-5 bg-surface-elevated rounded" />
<div className="w-12 h-3 bg-surface-elevated rounded" />
</div>
))}
</div>
{/* Description */}
<div className="space-y-2">
<div className="h-4 bg-panel rounded w-1/4 mb-3" />
{[...Array(5)].map((_, i) => (
<div key={i} className="h-3.5 bg-panel rounded" style={{ width: `${90 - i * 8}%` }} />
))}
</div>
{/* Amenities */}
<div className="space-y-2">
<div className="h-4 bg-panel rounded w-1/4 mb-3" />
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{[...Array(9)].map((_, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-4 h-4 bg-panel rounded" />
<div className="h-3.5 bg-panel rounded flex-1" />
</div>
))}
</div>
</div>
</div>
{/* Sidebar */}
<div className="w-full lg:w-72 flex-shrink-0">
<div className="bg-panel border border-white/5 rounded-xl p-5 space-y-4">
<div className="h-6 bg-surface-elevated rounded w-1/3" />
<div className="h-9 bg-surface-elevated rounded w-2/3" />
<div className="h-px bg-white/5" />
<div className="h-4 bg-surface-elevated rounded w-1/2" />
{/* Form skeleton */}
<div className="space-y-2 pt-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-10 bg-surface-elevated rounded-lg" />
))}
<div className="h-24 bg-surface-elevated rounded-lg" />
<div className="h-10 bg-brand-indigo/20 rounded-xl" />
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
interface StatsStripProps {
bedrooms: number
bathrooms: number
parking_spots: number
area_m2: number
}
function BedIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 9V4a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v5" />
<path d="M2 9h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z" />
<path d="M6 9v4" />
</svg>
)
}
function BathIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 6 6.5 3.5a1.5 1.5 0 0 0-1-.5C4.683 3 4 3.683 4 4.5V17a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5" />
<line x1="10" x2="8" y1="5" y2="7" />
<line x1="2" x2="22" y1="12" y2="12" />
<line x1="7" x2="7" y1="19" y2="21" />
<line x1="17" x2="17" y1="19" y2="21" />
</svg>
)
}
function CarIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 17H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v4" />
<circle cx="15" cy="17" r="2" />
<circle cx="7" cy="17" r="2" />
</svg>
)
}
function AreaIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 21V9" />
</svg>
)
}
interface StatItemProps {
icon: React.ReactNode
value: string | number
label: string
}
function StatItem({ icon, value, label }: StatItemProps) {
return (
<div className="flex flex-col items-center gap-1.5 px-4 py-3 flex-1">
<span className="text-textQuaternary">{icon}</span>
<span className="text-lg font-medium text-textPrimary">{value}</span>
<span className="text-xs text-textTertiary">{label}</span>
</div>
)
}
export default function StatsStrip({ bedrooms, bathrooms, parking_spots, area_m2 }: StatsStripProps) {
return (
<div className="flex items-stretch divide-x divide-white/5 bg-panel border border-white/5 rounded-xl">
<StatItem icon={<BedIcon />} value={bedrooms} label="Quartos" />
<StatItem icon={<BathIcon />} value={bathrooms} label="Banheiros" />
<StatItem icon={<CarIcon />} value={parking_spots} label="Vagas" />
<StatItem icon={<AreaIcon />} value={`${area_m2}`} label="Área" />
</div>
)
}

View file

@ -0,0 +1,142 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import type { Property } from '../types/property'
function formatPrice(price: string): string {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(parseFloat(price))
}
function isNew(createdAt: string | null): boolean {
if (!createdAt) return false
return Date.now() - new Date(createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
}
function BedIcon() {
return (
<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="M2 9V4a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v5" /><path d="M2 9h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z" /><path d="M6 9v4" />
</svg>
)
}
function AreaIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 21V9" />
</svg>
)
}
export default function PropertyGridCard({ property }: { property: Property }) {
const isVenda = property.type === 'venda'
const navigate = useNavigate()
const showNew = isNew(property.created_at)
const [imgLoaded, setImgLoaded] = useState(false)
const photo = property.photos[0]
return (
<article className="relative group bg-panel border border-borderSubtle rounded-2xl overflow-hidden hover:border-borderStandard transition-all duration-200 flex flex-col">
{/* Photo */}
<div className="relative w-full aspect-[4/3] bg-surface overflow-hidden">
{!imgLoaded && (
<div className="absolute inset-0 bg-white/[0.06] animate-pulse" />
)}
<img
src={photo?.url ?? '/placeholder-property.jpg'}
alt={photo?.alt_text ?? property.title}
onLoad={() => setImgLoaded(true)}
className={`w-full h-full object-cover transition-all duration-500 group-hover:scale-[1.02] ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
/>
{/* Overlay link */}
<Link
to={`/imoveis/${property.slug}`}
className="absolute inset-0 z-0"
tabIndex={-1}
aria-hidden="true"
/>
{/* Badges */}
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1 pointer-events-none">
{property.is_featured && (
<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">
Novo
</span>
)}
</div>
{/* Listing type */}
<div className="absolute top-2 right-2 z-10 pointer-events-none">
<span className={`inline-flex items-center rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm ${isVenda ? 'bg-brand/80 text-white' : 'bg-black/50 text-white/90 border border-white/20'}`}>
{isVenda ? 'Venda' : 'Aluguel'}
</span>
</div>
</div>
{/* Info */}
<div className="relative z-10 flex flex-col flex-1 p-4 gap-2 cursor-pointer" onClick={() => navigate(`/imoveis/${property.slug}`)}>
{/* Title */}
<h3 className="text-sm font-semibold text-textPrimary leading-snug line-clamp-2">
{property.title}
</h3>
{/* Location */}
{(property.city || property.neighborhood) && (
<p className="text-xs text-textTertiary truncate flex items-center gap-1">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" />
</svg>
{[property.neighborhood?.name, property.city?.name].filter(Boolean).join(', ')}
</p>
)}
{/* Price */}
<p className="text-base font-bold text-textPrimary tracking-tight">
{formatPrice(property.price)}
{!isVenda && <span className="text-xs text-textTertiary font-normal ml-1">/mês</span>}
</p>
{(property.condo_fee || property.iptu_anual) && (
<div className="flex items-center gap-2 flex-wrap">
{property.condo_fee && parseFloat(property.condo_fee) > 0 && (
<span className="text-[11px] text-textTertiary">
Cond. {formatPrice(property.condo_fee)}/mês
</span>
)}
{property.iptu_anual && parseFloat(property.iptu_anual) > 0 && (
<span className="text-[11px] text-textTertiary">
IPTU {formatPrice(String(parseFloat(property.iptu_anual) / 12))}/mês
</span>
)}
</div>
)}
{/* Stats */}
<div className="flex items-center gap-3 text-xs text-textSecondary">
<span className="flex items-center gap-1" title="Quartos"><BedIcon />{property.bedrooms}</span>
<span className="flex items-center gap-1" title="Área"><AreaIcon />{property.area_m2} m²</span>
</div>
{/* CTA */}
<div className="mt-auto pt-2">
<Link
to={`/imoveis/${property.slug}`}
onClick={(e) => e.stopPropagation()}
className="block w-full text-center rounded-lg px-3 py-2 text-xs font-semibold bg-brand text-white hover:bg-accentHover transition-colors"
>
Ver detalhes
</Link>
</div>
</div>
</article>
)
}

View file

@ -0,0 +1,344 @@
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 HeartButton from './HeartButton'
// ── Badge helpers ─────────────────────────────────────────────────────────────
function isNew(createdAt: string | null): boolean {
if (!createdAt) return false
return Date.now() - new Date(createdAt).getTime() < 7 * 24 * 60 * 60 * 1000
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatPrice(price: string): string {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(parseFloat(price))
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function BedIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 9V4a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v5" /><path d="M2 9h20v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z" /><path d="M6 9v4" />
</svg>
)
}
function BathIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 6 6.5 3.5a1.5 1.5 0 0 0-1-.5C4.683 3 4 3.683 4 4.5V17a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5" /><line x1="10" x2="8" y1="5" y2="7" /><line x1="2" x2="22" y1="12" y2="12" /><line x1="7" x2="7" y1="19" y2="21" /><line x1="17" x2="17" y1="19" y2="21" />
</svg>
)
}
function AreaIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 21V9" />
</svg>
)
}
function CarIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 17H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1l2-3h10l2 3h1a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-2" /><circle cx="7" cy="17" r="2" /><circle cx="17" cy="17" r="2" />
</svg>
)
}
function ChevronIcon({ dir }: { dir: 'left' | 'right' }) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{dir === 'left' ? <path d="M15 18l-6-6 6-6" /> : <path d="M9 18l6-6-6-6" />}
</svg>
)
}
// ── Photo carousel (fade + lazy load) ────────────────────────────────────────
interface Photo {
url: string
alt_text?: string
}
function SlideImage({ src, alt }: { src: string; alt: string }) {
const [loaded, setLoaded] = useState(false)
return (
<>
{/* Skeleton mostrado até a imagem carregar */}
{!loaded && (
<div className="absolute inset-0 bg-white/[0.06] animate-pulse" />
)}
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
className={`w-full h-full object-cover transition-opacity duration-500 ${loaded ? 'opacity-100' : 'opacity-0'}`}
draggable={false}
/>
</>
)
}
function PhotoCarousel({ photos, title, isNew: showNew, isFeatured }: {
photos: Photo[]
title: string
isNew?: boolean
isFeatured?: boolean
}) {
const slides = photos.length > 0 ? photos : [{ url: '/placeholder-property.jpg', alt_text: title }]
const [current, setCurrent] = useState(0)
function prev(e: React.MouseEvent) {
e.preventDefault()
e.stopPropagation()
setCurrent(i => (i - 1 + slides.length) % slides.length)
}
function next(e: React.MouseEvent) {
e.preventDefault()
e.stopPropagation()
setCurrent(i => (i + 1) % slides.length)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (slides.length <= 1) return
if (e.key === 'ArrowLeft') setCurrent(i => (i - 1 + slides.length) % slides.length)
if (e.key === 'ArrowRight') setCurrent(i => (i + 1) % slides.length)
}
return (
<div className="relative w-full h-full overflow-hidden" onKeyDown={handleKeyDown}>
{/* Slides com fade */}
{slides.map((photo, i) => (
<div
key={i}
className={`absolute inset-0 transition-opacity duration-400 ${i === current ? 'opacity-100 z-[1]' : 'opacity-0 z-0'}`}
>
<SlideImage src={photo.url} alt={photo.alt_text ?? title} />
</div>
))}
{/* Status badges */}
<div className="absolute top-2 left-2 z-20 flex flex-col gap-1 pointer-events-none">
{isFeatured && (
<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">
Novo
</span>
)}
</div>
{/* Prev / Next — visible on mobile, hover-only on desktop */}
{slides.length > 1 && (
<>
<button
onClick={prev}
aria-label="Foto anterior"
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-7 h-7 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 transition-colors opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
>
<ChevronIcon dir="left" />
</button>
<button
onClick={next}
aria-label="Próxima foto"
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-7 h-7 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 transition-colors opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
>
<ChevronIcon dir="right" />
</button>
</>
)}
{/* Dots — with larger touch area */}
{slides.length > 1 && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1 z-10">
{slides.map((_, i) => (
<button
key={i}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setCurrent(i) }}
aria-label={`Foto ${i + 1}`}
className="p-2 -m-2"
>
<span className={`block transition-all duration-200 rounded-full ${i === current
? 'w-4 h-1.5 bg-white'
: 'w-1.5 h-1.5 bg-white/50 hover:bg-white/80'
}`}
/>
</button>
))}
</div>
)}
</div>
)
}
// ── Row card ──────────────────────────────────────────────────────────────────
export default function PropertyRowCard({ property }: { property: Property }) {
const isVenda = property.type === 'venda'
const navigate = useNavigate()
const { isInComparison, add, remove, properties: comparisonItems } = useComparison()
const inComparison = isInComparison(property.id)
const comparisonFull = comparisonItems.length >= 3
const [contactOpen, setContactOpen] = useState(false)
const showNew = isNew(property.created_at)
return (
<article className="relative group bg-panel border border-borderSubtle rounded-2xl overflow-hidden hover:border-borderStandard transition-all duration-200 flex flex-col sm:flex-row sm:h-[220px]">
{/* ── Carousel (top on mobile, left on desktop) ──────────────── */}
<div className="relative flex-shrink-0 w-full h-48 sm:w-[280px] sm:h-full lg:w-[340px]">
<PhotoCarousel
photos={property.photos}
title={property.title}
isNew={showNew}
isFeatured={property.is_featured}
/>
{/* Listing type badge */}
<div className="absolute top-3 right-10 z-20 pointer-events-none">
<span className={`inline-flex items-center rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm shadow ${isVenda
? 'bg-brand/80 text-white'
: 'bg-black/50 text-white/90 border border-white/20'
}`}>
{isVenda ? 'Venda' : 'Aluguel'}
</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} />
</div>
</div>
{/* ── Overlay link (covers entire card) ──────────────────────── */}
<Link
to={`/imoveis/${property.slug}`}
className="absolute inset-0 z-0"
tabIndex={-1}
aria-hidden="true"
/>
{/* ── 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 */}
<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>
{/* Location */}
{(property.city || property.neighborhood) && (
<p className="text-xs text-textTertiary flex items-center gap-1 truncate">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" />
</svg>
{[property.neighborhood?.name, property.city?.name].filter(Boolean).join(', ')}
</p>
)}
{/* Price */}
<p className="text-lg font-bold text-textPrimary tracking-tight">
{formatPrice(property.price)}
{!isVenda && (
<span className="text-xs text-textTertiary font-normal ml-1">/mês</span>
)}
</p>
{(property.condo_fee || property.iptu_anual) && (
<div className="flex items-center gap-3 flex-wrap">
{property.condo_fee && parseFloat(property.condo_fee) > 0 && (
<span className="text-[11px] text-textTertiary">
Cond. {formatPrice(property.condo_fee)}/mês
</span>
)}
{property.iptu_anual && parseFloat(property.iptu_anual) > 0 && (
<span className="text-[11px] text-textTertiary">
IPTU {formatPrice(String(parseFloat(property.iptu_anual) / 12))}/mês
</span>
)}
</div>
)}
{/* Stats */}
<div className="flex items-center gap-3 text-xs text-textSecondary flex-wrap">
<span className="flex items-center gap-1" title="Quartos"><BedIcon />{property.bedrooms} quartos</span>
<span className="flex items-center gap-1" title="Banheiros"><BathIcon />{property.bathrooms} banheiros</span>
<span className="flex items-center gap-1" title="Área"><AreaIcon />{property.area_m2} m²</span>
{property.parking_spots > 0 && (
<span className="flex items-center gap-1" title="Vagas"><CarIcon />{property.parking_spots} vaga{property.parking_spots !== 1 ? 's' : ''}</span>
)}
</div>
{/* CTAs — primary / secondary / ghost hierarchy */}
<div className="mt-auto flex items-center gap-2 flex-wrap">
<Link
to={`/imoveis/${property.slug}`}
onClick={(e) => e.stopPropagation()}
className="rounded-lg px-3 py-1.5 text-xs font-semibold bg-brand text-white hover:bg-accentHover transition-colors"
>
Ver detalhes
</Link>
<button
onClick={(e) => { e.stopPropagation(); setContactOpen(true) }}
className="rounded-lg px-3 py-1.5 text-xs font-semibold border border-brand text-brand hover:bg-brand/10 transition-colors"
>
Entre em contato
</button>
{(!comparisonFull || inComparison) ? (
<button
onClick={(e) => { e.stopPropagation(); inComparison ? remove(property.id) : add(property) }}
className={`text-xs font-medium transition-colors px-2 py-1.5 rounded ${inComparison
? 'text-brand font-semibold'
: 'text-textTertiary hover:text-textSecondary'
}`}
>
{inComparison ? '✓ Comparando' : 'Comparar'}
</button>
) : (
<span
title="Máximo de 3 imóveis para comparar. Remova um para adicionar este."
className="text-xs text-textQuaternary cursor-not-allowed px-2 py-1.5"
>
Comparar
</span>
)}
</div>
</div>
{contactOpen && (
<ContactModal
propertySlug={property.slug}
propertyCode={property.code}
propertyTitle={property.title}
onClose={() => setContactOpen(false)}
/>
)}
</article>
)
}

View file

@ -0,0 +1,25 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
interface ProtectedRouteProps {
children?: React.ReactNode
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-canvas">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand border-t-transparent" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return children ? <>{children}</> : <Outlet />
}

View file

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
export default function ScrollToTopButton() {
const [visible, setVisible] = useState(false)
useEffect(() => {
function onScroll() {
setVisible(window.scrollY > 400)
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
if (!visible) return null
return (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
aria-label="Voltar ao topo"
className="fixed bottom-6 right-6 z-40 w-10 h-10 rounded-full bg-panel border border-borderStandard shadow-lg text-textSecondary hover:text-textPrimary hover:border-borderSubtle transition-all flex items-center justify-center"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
)
}

View file

@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from 'react'
interface SearchBarProps {
value: string
onSearch: (q: string) => void
placeholder?: string
}
export default function SearchBar({
value,
onSearch,
placeholder = 'Buscar por endereço, bairro ou código...',
}: SearchBarProps) {
const [local, setLocal] = useState(value)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Sync external value changes (e.g. URL reset)
useEffect(() => {
setLocal(value)
}, [value])
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const next = e.target.value
setLocal(next)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
onSearch(next)
}, 400)
}
function handleClear() {
setLocal('')
if (timerRef.current) clearTimeout(timerRef.current)
onSearch('')
}
return (
<div
role="search"
aria-label="Buscar imóveis"
className="relative flex items-center"
>
<svg
className="absolute left-3 text-textTertiary pointer-events-none"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="search"
value={local}
onChange={handleChange}
placeholder={placeholder}
className="w-full h-9 pl-9 pr-8 rounded-lg border border-borderSubtle bg-surface text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:border-brand/50 focus:ring-1 focus:ring-brand/30 transition"
aria-label="Buscar imóveis"
/>
{local && (
<button
onClick={handleClear}
aria-label="Limpar busca"
className="absolute right-2.5 text-textQuaternary hover:text-textSecondary transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
)
}

View file

@ -0,0 +1,47 @@
// Utilitário React para alternância de tema (ícone sol/lua)
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
export const ThemeToggle: React.FC<{ className?: string }> = ({ className }) => {
const { theme, resolvedTheme, setTheme } = useTheme();
const nextTheme =
theme === 'system'
? resolvedTheme === 'dark' ? 'light' : 'dark'
: theme === 'dark' ? 'light' : 'dark';
const handleToggle = () => setTheme(nextTheme);
return (
<button
onClick={handleToggle}
aria-label={`Alternar para tema ${nextTheme === 'dark' ? 'escuro' : 'claro'}`}
className={`focus:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded p-2 transition-colors ${className || ''}`}
tabIndex={0}
>
{resolvedTheme === 'dark' ? (
// Ícone Sol
<svg width="20" height="20" fill="none" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="5" fill="currentColor" />
<g stroke="currentColor" strokeWidth="1.5">
<line x1="10" y1="1.5" x2="10" y2="3.5" />
<line x1="10" y1="16.5" x2="10" y2="18.5" />
<line x1="3.5" y1="10" x2="1.5" y2="10" />
<line x1="18.5" y1="10" x2="16.5" y2="10" />
<line x1="4.64" y1="4.64" x2="3.22" y2="3.22" />
<line x1="15.36" y1="15.36" x2="16.78" y2="16.78" />
<line x1="4.64" y1="15.36" x2="3.22" y2="16.78" />
<line x1="15.36" y1="4.64" x2="16.78" y2="3.22" />
</g>
</svg>
) : (
// Ícone Lua
<svg width="20" height="20" fill="none" viewBox="0 0 20 20" aria-hidden="true">
<path
d="M15.5 10.5A5.5 5.5 0 0 1 9.5 4.5c0-.28.02-.56.06-.83a.5.5 0 0 0-.7-.53A7 7 0 1 0 16.86 11.14a.5.5 0 0 0-.53-.7c-.27.04-.55.06-.83.06Z"
fill="currentColor"
/>
</svg>
)}
</button>
);
};

View file

@ -0,0 +1,79 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { getMe, loginUser, registerUser } from '../services/auth'
import type { LoginCredentials, RegisterCredentials, User } from '../types/auth'
interface AuthContextValue {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
login: (data: LoginCredentials) => Promise<void>
register: (data: RegisterCredentials) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(() => localStorage.getItem('auth_token'))
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const storedToken = localStorage.getItem('auth_token')
if (storedToken) {
getMe()
.then(setUser)
.catch(() => {
localStorage.removeItem('auth_token')
setToken(null)
})
.finally(() => setIsLoading(false))
} else {
setIsLoading(false)
}
}, [])
const login = useCallback(async (data: LoginCredentials) => {
const response = await loginUser(data)
localStorage.setItem('auth_token', response.access_token)
setToken(response.access_token)
setUser(response.user)
}, [])
const register = useCallback(async (data: RegisterCredentials) => {
const response = await registerUser(data)
localStorage.setItem('auth_token', response.access_token)
setToken(response.access_token)
setUser(response.user)
}, [])
const logout = useCallback(() => {
localStorage.removeItem('auth_token')
setToken(null)
setUser(null)
window.location.href = '/login'
}, [])
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}

View file

@ -0,0 +1,60 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { Property } from '../types/property';
const STORAGE_KEY = 'imob_comparison';
const MAX_PROPERTIES = 3;
interface ComparisonContextValue {
properties: Property[];
add: (property: Property) => void;
remove: (propertyId: string) => void;
clear: () => void;
isInComparison: (propertyId: string) => boolean;
}
const ComparisonContext = createContext<ComparisonContextValue | null>(null);
export function ComparisonProvider({ children }: { children: React.ReactNode }) {
const [properties, setProperties] = useState<Property[]>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(properties));
}, [properties]);
const add = useCallback((property: Property) => {
setProperties(prev => {
if (prev.find(p => p.id === property.id)) return prev;
if (prev.length >= MAX_PROPERTIES) return prev;
return [...prev, property];
});
}, []);
const remove = useCallback((propertyId: string) => {
setProperties(prev => prev.filter(p => p.id !== propertyId));
}, []);
const clear = useCallback(() => setProperties([]), []);
const isInComparison = useCallback((propertyId: string) => {
return properties.some(p => p.id === propertyId);
}, [properties]);
return (
<ComparisonContext.Provider value={{ properties, add, remove, clear, isInComparison }}>
{children}
</ComparisonContext.Provider>
);
}
export function useComparison() {
const ctx = useContext(ComparisonContext);
if (!ctx) throw new Error('useComparison must be used inside ComparisonProvider');
return ctx;
}

View file

@ -0,0 +1,71 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { addFavorite, getFavorites, removeFavorite } from '../services/clientArea';
import { useAuth } from './AuthContext';
interface FavoritesContextValue {
favoriteIds: Set<string>;
toggle: (propertyId: string) => Promise<void>;
isLoading: boolean;
}
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 [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
setFavoriteIds(new Set());
return;
}
setIsLoading(true);
getFavorites()
.then(saved => {
// saved is SavedProperty[] — need property_id values
const ids = saved
.filter((s: any) => s.property_id)
.map((s: any) => s.property_id as string);
setFavoriteIds(new Set(ids));
})
.catch(() => setFavoriteIds(new Set()))
.finally(() => setIsLoading(false));
}, [isAuthenticated]);
const toggle = useCallback(async (propertyId: string) => {
if (!isAuthenticated) return;
const wasIn = favoriteIds.has(propertyId);
// Optimistic update
setFavoriteIds(prev => {
const next = new Set(prev);
if (wasIn) next.delete(propertyId);
else next.add(propertyId);
return next;
});
try {
if (wasIn) await removeFavorite(propertyId);
else await addFavorite(propertyId);
} catch {
// Rollback
setFavoriteIds(prev => {
const next = new Set(prev);
if (wasIn) next.add(propertyId);
else next.delete(propertyId);
return next;
});
}
}, [isAuthenticated, favoriteIds]);
return (
<FavoritesContext.Provider value={{ favoriteIds, toggle, isLoading }}>
{children}
</FavoritesContext.Provider>
);
}
export function useFavorites() {
const ctx = useContext(FavoritesContext);
if (!ctx) throw new Error('useFavorites must be used inside FavoritesProvider');
return ctx;
}

View file

@ -0,0 +1,71 @@
// Contexto para alternância de tema (light/dark/system) com persistência e sincronização com prefers-color-scheme
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextProps {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);
function getSystemTheme(): 'light' | 'dark' {
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as Theme) || 'system';
}
return 'system';
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(getSystemTheme());
useEffect(() => {
const root = window.document.documentElement;
let applied: 'light' | 'dark';
if (theme === 'system') {
applied = getSystemTheme();
} else {
applied = theme;
}
setResolvedTheme(applied);
root.classList.remove('light', 'dark');
root.classList.add(applied);
}, [theme]);
useEffect(() => {
if (theme !== 'system') {
localStorage.setItem('theme', theme);
} else {
localStorage.removeItem('theme');
}
}, [theme]);
// Listen to system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setResolvedTheme(mq.matches ? 'dark' : 'light');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme: setThemeState }}>
{children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

181
frontend/src/index.css Normal file
View file

@ -0,0 +1,181 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Light theme tokens */
--color-canvas: #f7f8fa;
--color-panel: #fff;
--color-surface: #f3f4f6;
--color-surface-secondary: #e5e7eb;
--color-text-primary: #18191a;
--color-text-secondary: #3e3e44;
--color-text-tertiary: #62666d;
--color-text-quaternary: #8a8f98;
--color-text-muted: #ababab;
--color-brand: #5e6ad2;
--color-accent: #7170ff;
--color-accent-hover: #5e6ad2;
--color-security: #7a7fad;
--color-status-green: #27a644;
--color-status-emerald: #10b981;
--color-border-primary: #e5e7eb;
--color-border-secondary: #d1d5db;
--color-border-tertiary: #cbd5e1;
--color-line-tint: #e5e7eb;
--color-line-tertiary: #d1d5db;
--border-subtle: rgba(0, 0, 0, 0.05);
--border-standard: rgba(0, 0, 0, 0.08);
/* RGB channels for opacity-modifier support (border-accent/60, ring-accent/30) */
--color-accent-ch: 113 112 255;
--color-brand-ch: 94 106 210;
/* ─── Admin badge color — easy to swap ─── */
--color-admin: #f5c518;
/* Navbar backdrop */
--navbar-bg: rgba(247, 248, 250, 0.85);
/* Scrollbar */
--scrollbar-track: #f3f4f6;
--scrollbar-thumb: #d1d5db;
--scrollbar-thumb-hover: #9ca3af;
}
.dark {
/* Dark theme tokens */
--color-canvas: #08090a;
--color-panel: #0f1011;
--color-surface: #191a1b;
--color-surface-secondary: #28282c;
--color-text-primary: #f7f8f8;
--color-text-secondary: #d0d6e0;
--color-text-tertiary: #8a8f98;
--color-text-quaternary: #62666d;
--color-text-muted: #ababab;
--color-brand: #5e6ad2;
--color-accent: #7170ff;
--color-accent-hover: #828fff;
--color-security: #7a7fad;
--color-status-green: #27a644;
--color-status-emerald: #10b981;
--color-border-primary: #23252a;
--color-border-secondary: #34343a;
--color-border-tertiary: #3e3e44;
--color-line-tint: #141516;
--color-line-tertiary: #18191a;
--border-subtle: rgba(255, 255, 255, 0.05);
--border-standard: rgba(255, 255, 255, 0.08);
/* ─── Admin badge color — easy to swap ─── */
--color-admin: #f5c518;
/* Navbar backdrop */
--navbar-bg: rgba(8, 9, 10, 0.85);
/* Scrollbar */
--scrollbar-track: #0f1011;
--scrollbar-thumb: #28282c;
--scrollbar-thumb-hover: #3e3e44;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-canvas text-textPrimary font-sans antialiased;
font-feature-settings: "cv01", "ss03";
}
/* Focus ring — accessible but minimal */
:focus-visible {
outline: 2px solid rgba(113, 112, 255, 0.6);
outline-offset: 2px;
}
/* Scrollbar styling — theme-aware */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
}
@layer components {
.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";
}
.btn-primary:focus-visible {
outline: 2px solid rgba(113, 112, 255, 0.6);
outline-offset: 2px;
}
/* Card theme-aware (substitui card-dark) */
.card {
@apply bg-panel border border-borderSubtle rounded-xl overflow-hidden;
}
/* Alias retrocompatível */
.card-dark {
@apply bg-panel border border-borderSubtle rounded-xl overflow-hidden;
}
/* 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;
}
/* Labels de formulário theme-aware */
.form-label {
@apply text-xs font-medium text-textSecondary uppercase tracking-wide;
}
/* Seção de card em formulários */
.form-section {
@apply rounded-xl border border-borderSubtle bg-panel p-6 space-y-4;
}
}
@layer utilities {
/* Stagger entry animation for property cards */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.25s ease both;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up {
animation: none;
}
}
}

View file

@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom';
import Navbar from '../components/Navbar';
export default function AdminLayout() {
return (
<>
<Navbar />
<main className="pt-14 min-h-screen bg-canvas">
<Outlet />
</main>
</>
);
}

View file

@ -0,0 +1,134 @@
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: '⚙️' },
];
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>
{/* 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>
<Outlet />
</main>
</div>
);
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import { ThemeProvider } from './contexts/ThemeContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>,
)

View file

@ -0,0 +1,259 @@
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
interface DiferencialItem {
icon: React.ReactNode
title: string
description: string
}
interface MetricItem {
value: string
label: string
}
const diferenciais: DiferencialItem[] = [
{
icon: (
<svg className="w-6 h-6" 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>
),
title: 'Especialistas locais',
description: 'Profundo conhecimento dos bairros, preços e tendências do mercado imobiliário regional.',
},
{
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
title: 'Atendimento personalizado',
description: 'Cada cliente tem necessidades únicas. Trabalhamos para entender o seu perfil e encontrar o match perfeito.',
},
{
icon: (
<svg className="w-6 h-6" 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: 'Processo transparente',
description: 'Da visita ao contrato assinado, mantemos você informado em cada etapa. Sem surpresas, sem letras miúdas.',
},
{
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
title: 'Agilidade e tecnologia',
description: 'Plataforma digital completa para buscar, salvar e comparar imóveis — onde e quando quiser.',
},
]
const metricas: MetricItem[] = [
{ value: '10+', label: 'Anos de mercado' },
{ value: '500+', label: 'Imóveis negociados' },
{ value: '98%', label: 'Clientes satisfeitos' },
{ value: '8', label: 'Corretores especializados' },
]
export default function AboutPage() {
return (
<>
<Navbar />
<main id="main-content" className="min-h-screen bg-canvas">
{/* ── Hero ──────────────────────────────────────────────────── */}
<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">
Quem somos
</p>
<h1
className="text-3xl md:text-5xl font-semibold text-textPrimary tracking-tight max-w-[640px] leading-tight"
style={{ fontFeatureSettings: '"cv01","ss03"' }}
>
Ajudamos você a encontrar o imóvel dos seus sonhos
</h1>
<p className="mt-5 text-textSecondary text-base md:text-lg leading-relaxed max-w-[580px]">
Desde 2014, a ImobiliáriaHub conecta pessoas a imóveis com transparência,
segurança e um atendimento verdadeiramente personalizado. Somos mais do
que uma imobiliária somos parceiros na realização dos seus planos.
</p>
</section>
{/* ── Nossa história ───────────────────────────────────────── */}
<section className="border-t border-white/[0.06]">
<div className="max-w-[1080px] mx-auto px-6 py-16 grid md:grid-cols-2 gap-12 items-center">
<div>
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
Nossa história
</p>
<h2 className="text-2xl md:text-3xl font-semibold text-textPrimary tracking-tight mb-5">
Uma década construindo confiança
</h2>
<div className="space-y-4 text-textSecondary leading-relaxed">
<p>
Fundada em 2014, a ImobiliáriaHub nasceu da percepção de que o
mercado imobiliário precisava de mais humanidade e menos burocracia.
Começamos com uma pequena equipe e um único escritório, mas com um
propósito claro: simplificar a jornada de quem quer comprar, vender
ou alugar um imóvel.
</p>
<p>
Ao longo dos anos, investimos em tecnologia para trazer o processo
inteiro para a palma da mão dos nossos clientes sem abrir mão do
contato humano que faz toda a diferença na hora de tomar uma decisão
tão importante.
</p>
<p>
Hoje, com mais de 500 negócios concluídos e uma equipe de 8
corretores experientes, seguimos crescendo com o mesmo DNA:
transparência, agilidade e compromisso com o resultado do cliente.
</p>
</div>
</div>
{/* Timeline visual */}
<div className="flex flex-col gap-4">
{[
{ year: '2014', text: 'Fundação da ImobiliáriaHub com foco em locações residenciais' },
{ year: '2017', text: 'Expansão para imóveis comerciais e lançamentos' },
{ year: '2020', text: 'Lançamento da plataforma digital com tour virtual' },
{ year: '2024', text: '10 anos conectando famílias aos melhores imóveis' },
].map((item) => (
<div
key={item.year}
className="flex gap-4 items-start bg-panel/40 border border-white/[0.06] rounded-xl p-4 hover:border-white/[0.1] transition-colors"
>
<span className="flex-shrink-0 text-[#5e6ad2] font-bold text-sm w-10 pt-0.5">
{item.year}
</span>
<p className="text-textSecondary text-sm leading-relaxed">{item.text}</p>
</div>
))}
</div>
</div>
</section>
{/* ── Métricas ─────────────────────────────────────────────── */}
<section className="border-t border-white/[0.06] bg-panel/20">
<div className="max-w-[1080px] mx-auto px-6 py-14">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{metricas.map((m) => (
<div key={m.label} className="text-center">
<p
className="text-3xl md:text-4xl font-bold text-[#5e6ad2]"
style={{ fontFeatureSettings: '"tnum"' }}
>
{m.value}
</p>
<p className="text-textSecondary text-sm mt-1.5">{m.label}</p>
</div>
))}
</div>
</div>
</section>
{/* ── Diferenciais ─────────────────────────────────────────── */}
<section className="border-t border-white/[0.06]">
<div className="max-w-[1080px] mx-auto px-6 py-16">
<div className="text-center mb-12">
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
Por que nos escolher
</p>
<h2 className="text-2xl md:text-3xl font-semibold text-textPrimary tracking-tight">
Nossos diferenciais
</h2>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
{diferenciais.map((d) => (
<div
key={d.title}
className="bg-panel/40 border border-white/[0.06] rounded-2xl p-6 hover:border-white/[0.12] transition-colors"
>
<div className="w-11 h-11 rounded-xl bg-[#5e6ad2]/15 border border-[#5e6ad2]/20 flex items-center justify-center text-[#5e6ad2] mb-4">
{d.icon}
</div>
<h3 className="text-textPrimary font-semibold text-sm mb-2">
{d.title}
</h3>
<p className="text-textSecondary text-xs leading-relaxed">
{d.description}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Nossa equipe ─────────────────────────────────────────── */}
<section className="border-t border-white/[0.06] bg-panel/20">
<div className="max-w-[1080px] mx-auto px-6 py-16 flex flex-col md:flex-row items-center justify-between gap-8">
<div className="max-w-[480px]">
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
Nossa equipe
</p>
<h2 className="text-2xl md:text-3xl font-semibold text-textPrimary tracking-tight mb-4">
Pessoas que fazem a diferença
</h2>
<p className="text-textSecondary leading-relaxed">
Nosso time é formado por corretores apaixonados pelo que fazem.
Cada profissional combina expertise técnica com escuta ativa para
entender o que você realmente precisa e entregar mais do que você
expect.
</p>
</div>
<a
href="/corretores"
className="flex-shrink-0 inline-flex items-center gap-2 px-6 py-3 rounded-full bg-[#5e6ad2] hover:bg-[#6e7ae0] text-white text-sm font-medium transition-colors shadow-lg shadow-[#5e6ad2]/20"
>
Conheça os corretores
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</section>
{/* ── CTA final ────────────────────────────────────────────── */}
<section className="border-t border-white/[0.06]">
<div className="max-w-[1080px] mx-auto px-6 py-20 text-center">
<h2 className="text-2xl md:text-3xl font-semibold text-textPrimary tracking-tight mb-4">
Pronto para encontrar seu imóvel?
</h2>
<p className="text-textSecondary mb-8 max-w-[460px] mx-auto leading-relaxed">
Explore nosso catálogo ou fale diretamente com um de nossos
corretores. Estamos prontos para ajudar.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<a
href="/imoveis"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-[#5e6ad2] hover:bg-[#6e7ae0] text-white text-sm font-medium transition-colors shadow-lg shadow-[#5e6ad2]/20"
>
Ver imóveis
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
<a
href="https://wa.me/5511999999999?text=Ol%C3%A1%2C%20vim%20pelo%20site%20e%20gostaria%20de%20mais%20informa%C3%A7%C3%B5es"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 rounded-full border border-white/[0.1] hover:border-white/20 text-textSecondary hover:text-textPrimary text-sm font-medium transition-colors"
>
<svg className="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
Falar no WhatsApp
</a>
</div>
</div>
</section>
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import AgentCard from '../components/AgentCard'
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
import { getAgents } from '../services/agents'
import type { Agent } from '../types/agent'
function AgentCardSkeleton() {
return (
<div className="flex flex-col items-center gap-4 bg-panel border border-borderSubtle rounded-2xl p-6 animate-pulse">
<div className="w-24 h-24 rounded-full bg-white/[0.06]" />
<div className="w-full space-y-2">
<div className="h-4 bg-white/[0.06] rounded w-3/4 mx-auto" />
<div className="h-3 bg-white/[0.06] rounded w-1/2 mx-auto" />
</div>
<div className="w-full space-y-2 border-t border-borderSubtle pt-4">
<div className="h-3 bg-white/[0.06] rounded w-full" />
<div className="h-3 bg-white/[0.06] rounded w-4/5" />
</div>
</div>
)
}
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
getAgents()
.then(setAgents)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<>
<Navbar />
<main id="main-content" className="min-h-screen bg-canvas">
{/* Header */}
<div className="max-w-[1200px] mx-auto px-6 pt-16 pb-10">
<p className="text-[#5e6ad2] text-sm font-medium tracking-widest uppercase mb-3">
Nossa equipe
</p>
<h1
className="text-3xl md:text-4xl font-semibold text-textPrimary tracking-tight"
style={{ fontFeatureSettings: '"cv01","ss03"' }}
>
Conheça nossos corretores
</h1>
<p className="mt-2 text-textSecondary text-base max-w-[560px]">
Profissionais especializados prontos para ajudar você a encontrar o imóvel ideal.
</p>
</div>
{/* Grid */}
<div className="max-w-[1200px] mx-auto px-6 pb-20">
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => (
<AgentCardSkeleton key={i} />
))}
</div>
) : agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-16 h-16 rounded-full bg-white/[0.04] flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p className="text-textSecondary text-sm">Nenhum corretor cadastrado ainda.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
{agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
)}
</div>
{/* CTA trabalhe conosco */}
<div
className="border-t border-borderSubtle"
style={{ background: '#08090a' }}
>
<div className="max-w-[1200px] mx-auto px-6 py-16 flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<h2 className="text-xl font-semibold text-textPrimary">Seja nosso colaborador</h2>
<p className="text-textSecondary text-sm mt-1">
Envie seu currículo e venha fazer parte da nossa equipe!
</p>
</div>
<a
href="/trabalhe-conosco"
className="flex-shrink-0 inline-flex items-center gap-2 px-6 py-2.5 rounded-full border border-white/[0.12] text-textPrimary text-sm font-medium hover:bg-white/[0.04] transition-colors"
>
Trabalhe conosco
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,77 @@
import { useEffect, useState } from 'react'
import AgentsCarousel from '../components/AgentsCarousel'
import Footer from '../components/Footer'
import HomeScrollScene from '../components/HomeScrollScene'
import Navbar from '../components/Navbar'
import { getHomepageConfig } from '../services/homepage'
import type { HomepageConfig } from '../types/homepage'
const FALLBACK_CONFIG: HomepageConfig = {
hero_headline: 'Encontre o imóvel dos seus sonhos',
hero_subheadline: 'Os melhores imóveis para comprar ou alugar na sua região',
hero_cta_label: 'Ver Imóveis',
hero_cta_url: '#imoveis',
featured_properties_limit: 6,
}
export default function HomePage() {
const [config, setConfig] = useState<HomepageConfig>(FALLBACK_CONFIG)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
getHomepageConfig()
.then((data) => {
setConfig(data)
})
.catch(() => {
// Silently fall back to FALLBACK_CONFIG — already set in useState
})
.finally(() => {
setIsLoading(false)
})
}, [])
return (
<>
<Navbar />
<main id="main-content">
<HomeScrollScene
headline={config.hero_headline}
subheadline={config.hero_subheadline ?? null}
ctaLabel={config.hero_cta_label}
ctaUrl={config.hero_cta_url}
backgroundImage={config.hero_image_url ?? null}
isLoading={isLoading}
/>
{/* ── Corretores Carousel ───────────────────────────────────── */}
<div className="bg-canvas border-t border-borderSubtle">
<div className="max-w-[1080px] mx-auto px-6 py-16">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-10">
<div>
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-2">Nossa equipe</p>
<h2 className="text-xl md:text-2xl font-semibold text-textPrimary tracking-tight">
Conheça nossos corretores
</h2>
<p className="text-textSecondary text-sm mt-1.5 max-w-[420px]">
Profissionais especializados prontos para ajudar você a encontrar o imóvel ideal.
</p>
</div>
<a
href="/corretores"
className="flex-shrink-0 inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-white/[0.08] text-textSecondary hover:text-textPrimary hover:border-white/20 text-sm font-medium transition-colors"
>
Ver todos
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
<AgentsCarousel />
</div>
</div>
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,107 @@
import { useState, type FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export default function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const from =
(location.state as { from?: { pathname: string } })?.from?.pathname ||
'/area-do-cliente'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await login({ email, password })
navigate(from, { replace: true })
} catch (err: unknown) {
const axiosErr = err as { response?: { status?: number } }
if (axiosErr.response?.status === 401) {
setError('E-mail ou senha incorretos.')
} else {
setError('Erro de conexão. Tente novamente.')
}
} finally {
setLoading(false)
}
}
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>
<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>
<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>
</div>
</div>
)
}

View file

@ -0,0 +1,307 @@
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
interface Section {
number: string
title: string
content: React.ReactNode
}
const sections: Section[] = [
{
number: '1',
title: 'Instituição responsável',
content: (
<>
<p>
A empresa responsável por coletar, processar e usar dados pessoais, nos termos da
Lei Geral de Proteção de Dados Pessoais (LGPD Lei 13.709/2018), é:
</p>
<div className="mt-4 pl-4 border-l-2 border-[#5e6ad2]/40 space-y-1 text-textSecondary">
<p className="font-medium text-textPrimary">ImobiliáriaHub</p>
<p>CNPJ: 00.000.000/0001-00</p>
<p>Endereço: Rua Exemplo, 1000 São Paulo/SP CEP 01001-000</p>
<p>
E-mail:{' '}
<a
href="mailto:contato@imobiliariahub.com.br"
className="text-[#5e6ad2] hover:underline"
>
contato@imobiliariahub.com.br
</a>
</p>
</div>
<p className="mt-4">
Esta declaração tem como objetivo divulgar informações sobre a coleta, o
processamento e o uso de dados pessoais em nosso site.
</p>
</>
),
},
{
number: '2',
title: 'Coleta e uso de dados pessoais',
content: (
<>
<p>
Dados pessoais são informações que permitem identificação, tais como nome,
e-mail, telefone ou endereço. Coletamos dados pessoais apenas quando
especificamente fornecidos por você, por exemplo:
</p>
<ul className="mt-3 space-y-1.5 list-disc list-inside marker:text-[#5e6ad2]">
<li>Cadastro de conta no site;</li>
<li>Envio de mensagens pelo formulário de contato;</li>
<li>Solicitação de visita a um imóvel;</li>
<li>Subscrição em listas de novidades e lançamentos;</li>
<li>Simulação de financiamento ou proposta comercial.</li>
</ul>
<p className="mt-4">
Armazenamos, usamos ou transferimos seus dados apenas com seu consentimento
e em situações específicas, como responder às suas dúvidas, processar
pedidos de visita ou informá-lo sobre imóveis do seu interesse.
</p>
</>
),
},
{
number: '3',
title: 'Transferência de dados pessoais',
content: (
<>
<p>
Não vendemos, alugamos nem compartilhamos seus dados pessoais com terceiros
para fins comerciais próprios desses terceiros.
</p>
<p className="mt-3">
Podemos compartilhar informações com parceiros de confiança que nos auxiliam
na operação do site (ex.: serviços de e-mail transacional, hospedagem em
nuvem), sempre sob obrigação de sigilo e em conformidade com a LGPD. Toda
transferência internacional de dados, quando necessária, seguirá os
requisitos legais aplicáveis.
</p>
</>
),
},
{
number: '4',
title: 'Uso de cookies',
content: (
<>
<p>
Nosso site utiliza cookies pequenos arquivos de texto salvos no seu
dispositivo para melhorar a experiência de navegação. Os cookies podem
ser:
</p>
<ul className="mt-3 space-y-1.5 list-disc list-inside marker:text-[#5e6ad2]">
<li>
<span className="font-medium text-textPrimary">Essenciais:</span>{' '}
necessários para o funcionamento básico do site (ex.: autenticação,
preferências de sessão);
</li>
<li>
<span className="font-medium text-textPrimary">Analíticos:</span>{' '}
coletam informações sobre como o site é utilizado, de forma anônima e
agregada;
</li>
<li>
<span className="font-medium text-textPrimary">Funcionais:</span>{' '}
lembram suas preferências para personalizar a experiência.
</li>
</ul>
<p className="mt-4">
Você pode configurar seu navegador para recusar cookies ou ser avisado
antes de aceitá-los. Observe que algumas funcionalidades do site podem não
estar disponíveis caso os cookies essenciais sejam bloqueados.
</p>
</>
),
},
{
number: '5',
title: 'Direitos do usuário (LGPD)',
content: (
<>
<p>
Nos termos da LGPD, você possui os seguintes direitos em relação aos seus
dados pessoais:
</p>
<ul className="mt-3 space-y-2 list-disc list-inside marker:text-[#5e6ad2]">
<li>
<span className="font-medium text-textPrimary">Acesso:</span> confirmar
a existência de tratamento e obter cópia dos seus dados;
</li>
<li>
<span className="font-medium text-textPrimary">Correção:</span>{' '}
solicitar a atualização de dados incompletos, inexatos ou desatualizados;
</li>
<li>
<span className="font-medium text-textPrimary">Exclusão:</span> pedir a
eliminação dos dados tratados com seu consentimento;
</li>
<li>
<span className="font-medium text-textPrimary">Portabilidade:</span>{' '}
receber seus dados em formato estruturado e interoperável;
</li>
<li>
<span className="font-medium text-textPrimary">
Revogação do consentimento:
</span>{' '}
retirar a qualquer momento o consentimento dado para o tratamento;
</li>
<li>
<span className="font-medium text-textPrimary">Informação:</span>{' '}
saber com quais entidades seus dados foram compartilhados.
</li>
</ul>
<p className="mt-4">
Para exercer qualquer desses direitos, entre em contato conosco pelo
e-mail indicado na seção 8.
</p>
</>
),
},
{
number: '6',
title: 'Segurança dos dados',
content: (
<>
<p>
Adotamos medidas técnicas e organizacionais adequadas para proteger seus
dados pessoais contra acesso não autorizado, perda acidental, destruição ou
divulgação indevida, incluindo:
</p>
<ul className="mt-3 space-y-1.5 list-disc list-inside marker:text-[#5e6ad2]">
<li>Criptografia das comunicações via HTTPS;</li>
<li>Controle de acesso restrito aos dados por colaboradores autorizados;</li>
<li>Senhas armazenadas com hash seguro (bcrypt);</li>
<li>Monitoramento e revisão periódica das práticas de segurança.</li>
</ul>
<p className="mt-4">
Em caso de incidente de segurança que possa gerar risco ou dano relevante,
notificaremos a Autoridade Nacional de Proteção de Dados (ANPD) e os
titulares afetados nos prazos previstos em lei.
</p>
</>
),
},
{
number: '7',
title: 'Alterações nesta política',
content: (
<p>
Esta Política de Privacidade pode ser atualizada periodicamente para refletir
mudanças nas nossas práticas ou obrigações legais. Alterações relevantes
serão comunicadas por meio de aviso em destaque no site ou por e-mail (quando
aplicável). A data da última revisão é exibida no topo desta página. Ao
continuar utilizando o site após as alterações, você concorda com a política
revisada.
</p>
),
},
{
number: '8',
title: 'Contato e Encarregado (DPO)',
content: (
<>
<p>
Para exercer seus direitos, esclarecer dúvidas ou registrar reclamações
sobre o tratamento dos seus dados pessoais, entre em contato com nosso
Encarregado de Proteção de Dados (DPO):
</p>
<div className="mt-4 pl-4 border-l-2 border-[#5e6ad2]/40 space-y-1 text-textSecondary">
<p>
E-mail:{' '}
<a
href="mailto:privacidade@imobiliariahub.com.br"
className="text-[#5e6ad2] hover:underline"
>
privacidade@imobiliariahub.com.br
</a>
</p>
<p>Prazo de resposta: até 15 dias úteis</p>
</div>
<p className="mt-4">
Você também poderá contatar a Autoridade Nacional de Proteção de Dados
(ANPD) pelo site{' '}
<a
href="https://www.gov.br/anpd"
target="_blank"
rel="noopener noreferrer"
className="text-[#5e6ad2] hover:underline"
>
www.gov.br/anpd
</a>
.
</p>
</>
),
},
]
const LAST_UPDATED = '17 de abril de 2026'
export default function PrivacyPolicyPage() {
return (
<>
<Navbar />
<main id="main-content" className="min-h-screen bg-canvas">
<div className="max-w-[800px] mx-auto px-6 pt-16 pb-24">
{/* Header */}
<div className="mb-12">
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
Legal
</p>
<h1
className="text-3xl md:text-4xl font-semibold text-textPrimary tracking-tight"
style={{ fontFeatureSettings: '"cv01","ss03"' }}
>
Política de Privacidade
</h1>
<p className="mt-3 text-textSecondary text-sm">
Última atualização: {LAST_UPDATED}
</p>
<p className="mt-4 text-textSecondary leading-relaxed max-w-[620px]">
A sua privacidade é importante para nós. Esta política descreve como
coletamos, usamos e protegemos seus dados pessoais em conformidade
com a Lei Geral de Proteção de Dados (LGPD Lei 13.709/2018).
</p>
</div>
{/* Sections */}
<div className="space-y-10">
{sections.map((section) => (
<section
key={section.number}
className="border border-borderSubtle rounded-2xl p-6 md:p-8 bg-panel/40"
>
<h2 className="flex items-baseline gap-3 text-lg font-semibold text-textPrimary mb-4">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-[#5e6ad2]/15 border border-[#5e6ad2]/25 flex items-center justify-center text-[#5e6ad2] text-xs font-bold">
{section.number}
</span>
{section.title}
</h2>
<div className="text-textSecondary text-sm leading-relaxed space-y-3">
{section.content}
</div>
</section>
))}
</div>
{/* Back link */}
<div className="mt-12 pt-8 border-t border-borderSubtle">
<a
href="/"
className="inline-flex items-center gap-2 text-sm text-textSecondary hover:text-textPrimary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Voltar para o início
</a>
</div>
</div>
</main>
<Footer />
</>
)
}

View file

@ -0,0 +1,594 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import ActiveFiltersBar from '../components/ActiveFiltersBar'
import EmptyStateWithSuggestions, { type EmptyStateSuggestion } from '../components/EmptyStateWithSuggestions'
import FilterSidebar from '../components/FilterSidebar'
import Footer from '../components/Footer'
import Navbar from '../components/Navbar'
import PropertyGridCard from '../components/PropertyGridCard'
import PropertyRowCard from '../components/PropertyRowCard'
import ScrollToTopButton from '../components/ScrollToTopButton'
import SearchBar from '../components/SearchBar'
import { getAmenities, getCities, getImobiliarias, getNeighborhoods, getPropertyTypes } from '../services/catalog'
import { getProperties, type PropertyFilters, type SortOption } from '../services/properties'
import type { Amenity, City, Imobiliaria, Neighborhood, PropertyType } from '../types/catalog'
import type { PaginatedProperties } from '../types/property'
type ViewMode = 'list' | 'grid'
// ── URL ↔ filter helpers ──────────────────────────────────────────────────────
function filtersFromParams(params: URLSearchParams): PropertyFilters {
const get = (k: string) => params.get(k) ?? undefined
const getNum = (k: string) => (params.has(k) ? Number(params.get(k)) : undefined)
return {
q: get('q'),
sort: (get('sort') as SortOption) ?? undefined,
listing_type: (get('listing_type') as 'venda' | 'aluguel') ?? undefined,
subtype_ids: params.has('subtype_ids')
? params.get('subtype_ids')!.split(',').map(Number)
: undefined,
imobiliaria_id: getNum('imobiliaria_id'),
city_id: getNum('city_id'),
neighborhood_ids: params.has('neighborhood_ids')
? params.get('neighborhood_ids')!.split(',').map(Number)
: undefined,
price_min: getNum('price_min'),
price_max: getNum('price_max'),
include_condo: params.get('include_condo') === 'true' || undefined,
bedrooms_min: getNum('bedrooms_min'),
bedrooms_max: getNum('bedrooms_max'),
bathrooms_min: getNum('bathrooms_min'),
bathrooms_max: getNum('bathrooms_max'),
parking_min: getNum('parking_min'),
parking_max: getNum('parking_max'),
area_min: getNum('area_min'),
area_max: getNum('area_max'),
amenity_ids: params.has('amenity_ids')
? params.get('amenity_ids')!.split(',').map(Number)
: undefined,
page: getNum('page') ?? 1,
per_page: 16,
}
}
function filtersToParams(filters: PropertyFilters): URLSearchParams {
const p = new URLSearchParams()
if (filters.q?.trim()) p.set('q', filters.q.trim())
if (filters.sort && filters.sort !== 'relevance') p.set('sort', filters.sort)
if (filters.listing_type) p.set('listing_type', filters.listing_type)
if (filters.subtype_ids?.length) p.set('subtype_ids', filters.subtype_ids.join(','))
if (filters.imobiliaria_id != null) p.set('imobiliaria_id', String(filters.imobiliaria_id))
if (filters.city_id != null) p.set('city_id', String(filters.city_id))
if (filters.neighborhood_ids?.length) p.set('neighborhood_ids', filters.neighborhood_ids.join(','))
if (filters.price_min != null) p.set('price_min', String(filters.price_min))
if (filters.price_max != null) p.set('price_max', String(filters.price_max))
if (filters.include_condo) p.set('include_condo', 'true')
if (filters.bedrooms_min) p.set('bedrooms_min', String(filters.bedrooms_min))
if (filters.bedrooms_max) p.set('bedrooms_max', String(filters.bedrooms_max))
if (filters.bathrooms_min) p.set('bathrooms_min', String(filters.bathrooms_min))
if (filters.bathrooms_max) p.set('bathrooms_max', String(filters.bathrooms_max))
if (filters.parking_min) p.set('parking_min', String(filters.parking_min))
if (filters.parking_max) p.set('parking_max', String(filters.parking_max))
if (filters.area_min != null) p.set('area_min', String(filters.area_min))
if (filters.area_max != null) p.set('area_max', String(filters.area_max))
if (filters.amenity_ids?.length) p.set('amenity_ids', filters.amenity_ids.join(','))
if (filters.page && filters.page > 1) p.set('page', String(filters.page))
return p
}
// ── Pagination component ──────────────────────────────────────────────────────
function Pagination({
current,
total,
onChange,
ariaLabel = 'Paginação',
}: {
current: number
total: number
onChange: (page: number) => void
ariaLabel?: string
}) {
if (total <= 1) return null
const pages: (number | '...')[] = []
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
} else {
pages.push(1)
if (current > 3) pages.push('...')
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
pages.push(i)
}
if (current < total - 2) pages.push('...')
pages.push(total)
}
return (
<nav aria-label={ariaLabel} className="flex items-center justify-center gap-1.5">
<button
onClick={() => onChange(current - 1)}
disabled={current === 1}
className="h-8 px-3 rounded-lg border border-borderSubtle text-xs text-textSecondary hover:border-borderStandard hover:text-textPrimary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Anterior
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="text-textTertiary text-xs px-1">
</span>
) : (
<button
key={p}
onClick={() => onChange(p)}
aria-current={p === current ? 'page' : undefined}
className={`w-8 h-8 rounded-lg text-xs border transition-colors ${p === current
? 'bg-brand border-brand text-white font-medium'
: 'border-borderSubtle text-textSecondary hover:border-borderStandard hover:text-textPrimary'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => onChange(current + 1)}
disabled={current === total}
className="h-8 px-3 rounded-lg border border-borderSubtle text-xs text-textSecondary hover:border-borderStandard hover:text-textPrimary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Próxima
</button>
</nav>
)
}
// ── Skeletons ─────────────────────────────────────────────────────────────────
function PropertyRowSkeleton() {
return (
<div className="flex flex-col sm:flex-row sm:h-[220px] bg-panel border border-borderSubtle rounded-2xl overflow-hidden animate-pulse">
<div className="flex-shrink-0 w-full h-48 sm:w-[280px] sm:h-full lg:w-[340px] bg-surface" />
<div className="flex flex-col flex-1 p-5 gap-3">
<div className="h-4 bg-surface rounded w-3/4" />
<div className="h-3 bg-surface rounded w-1/3" />
<div className="h-5 bg-surface rounded w-1/2" />
<div className="flex gap-3 mt-1">
<div className="h-3 bg-surface rounded w-16" />
<div className="h-3 bg-surface rounded w-16" />
<div className="h-3 bg-surface rounded w-14" />
</div>
<div className="mt-auto h-7 bg-surface rounded w-28" />
</div>
</div>
)
}
function PropertyGridSkeleton() {
return (
<div className="bg-panel border border-borderSubtle rounded-2xl overflow-hidden animate-pulse">
<div className="w-full aspect-[4/3] bg-surface" />
<div className="p-4 flex flex-col gap-3">
<div className="h-4 bg-surface rounded w-3/4" />
<div className="h-3 bg-surface rounded w-1/2" />
<div className="h-5 bg-surface rounded w-1/3" />
<div className="h-8 bg-surface rounded mt-2" />
</div>
</div>
)
}
// ── Mobile filter toggle ──────────────────────────────────────────────────────
function FilterIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="16" y2="12" />
<line x1="11" y1="18" x2="13" y2="18" />
</svg>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'relevance', label: 'Relevância' },
{ value: 'price_asc', label: 'Menor preço' },
{ value: 'price_desc', label: 'Maior preço' },
{ value: 'area_desc', label: 'Maior área' },
{ value: 'newest', label: 'Mais recente' },
]
export default function PropertiesPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [filters, setFilters] = useState<PropertyFilters>(() => filtersFromParams(searchParams))
const [result, setResult] = useState<PaginatedProperties | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [catalogLoading, setCatalogLoading] = useState(true)
const [propertyTypes, setPropertyTypes] = useState<PropertyType[]>([])
const [amenities, setAmenities] = useState<Amenity[]>([])
const [cities, setCities] = useState<City[]>([])
const [neighborhoods, setNeighborhoods] = useState<Neighborhood[]>([])
const [imobiliarias, setImobiliarias] = useState<Imobiliaria[]>([])
const [sidebarOpen, setSidebarOpen] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const stored = localStorage.getItem('imoveis_view_mode')
return stored === 'grid' ? 'grid' : 'list'
})
const [suggestions, setSuggestions] = useState<EmptyStateSuggestion[]>([])
const resultsRef = useRef<HTMLDivElement>(null)
// Load catalog data (non-blocking)
useEffect(() => {
Promise.all([getPropertyTypes(), getAmenities(), getCities(), getNeighborhoods(), getImobiliarias()])
.then(([types, ams, ctys, nbhs, imobs]) => {
setPropertyTypes(types)
setAmenities(ams)
setCities(ctys)
setNeighborhoods(nbhs)
setImobiliarias(imobs)
})
.finally(() => setCatalogLoading(false))
}, [])
// Fetch properties whenever filters change
const fetchProperties = useCallback(async (f: PropertyFilters) => {
setLoading(true)
setError(null)
setSuggestions([])
try {
const data = await getProperties(f)
setResult(data)
if (data.total === 0 && hasActiveFilters(f)) {
computeSuggestions(f).then(setSuggestions)
}
} catch {
setError('Não foi possível carregar os imóveis. Tente novamente.')
} finally {
setLoading(false)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchProperties(filters)
setSearchParams(filtersToParams(filters), { replace: true })
}, [filters]) // eslint-disable-line react-hooks/exhaustive-deps
function handleFiltersChange(next: PropertyFilters) {
setFilters({ ...next, page: next.page ?? 1, per_page: 16 })
}
function handleClear() {
setFilters({ page: 1, per_page: 16 })
}
function handlePageChange(page: number) {
setFilters((prev) => ({ ...prev, page }))
resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function handleViewMode(mode: ViewMode) {
setViewMode(mode)
localStorage.setItem('imoveis_view_mode', mode)
}
const activeFilterCount = [
filters.q?.trim(),
filters.listing_type,
...(filters.subtype_ids ?? []).map(() => true),
filters.imobiliaria_id != null,
filters.city_id != null,
...(filters.neighborhood_ids ?? []).map(() => true),
filters.price_min != null,
filters.price_max != null,
filters.bedrooms_min,
filters.bathrooms_min,
filters.parking_min,
filters.area_min != null,
filters.area_max != null,
(filters.amenity_ids?.length ?? 0) > 0,
].filter(Boolean).length
const perPage = filters.per_page ?? 16
const page = filters.page ?? 1
const showPositionIndicator = result && result.total > 0 && !loading
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, result?.total ?? 0)
return (
<div className="min-h-screen bg-canvas text-textPrimary">
<Navbar />
<main id="main-content" aria-label="Listagem de imóveis" className="pt-14">
{/* ── Page header ─────────────────────────────────────────── */}
<div className="border-b border-borderSubtle">
<div className="max-w-[1400px] mx-auto px-6 py-4">
{/* Search bar — full width */}
<div className="mb-4">
<SearchBar
value={filters.q ?? ''}
onSearch={(q) => setFilters(prev => ({ ...prev, q: q || undefined, page: 1 }))}
/>
</div>
{/* Second row: title/count + sort + view toggle + mobile filter btn */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h1 className="text-lg font-semibold text-textPrimary">Imóveis</h1>
{result && !loading && (
<p className="text-sm text-textTertiary mt-0.5">
{result.total === 0
? 'Nenhum resultado encontrado'
: `${result.total} imóvel${result.total !== 1 ? 'is' : ''} encontrado${result.total !== 1 ? 's' : ''}`}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Sort */}
<select
value={filters.sort ?? 'relevance'}
onChange={e => setFilters(prev => ({ ...prev, sort: e.target.value as SortOption, page: 1 }))}
className="h-8 rounded-lg border border-borderSubtle bg-surface text-xs text-textSecondary px-2 focus:outline-none focus:border-brand/50 cursor-pointer"
aria-label="Ordenar por"
>
{SORT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* View toggle */}
<div className="hidden sm:flex items-center gap-1 rounded-lg border border-borderSubtle p-0.5 bg-surface">
<button
onClick={() => handleViewMode('list')}
aria-pressed={viewMode === 'list'}
aria-label="Visualização em lista"
title="Lista"
className={`w-7 h-7 rounded flex items-center justify-center transition-colors ${viewMode === 'list' ? 'bg-panel shadow-sm text-textPrimary' : 'text-textTertiary hover:text-textSecondary'}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" /></svg>
</button>
<button
onClick={() => handleViewMode('grid')}
aria-pressed={viewMode === 'grid'}
aria-label="Visualização em grade"
title="Grade"
className={`w-7 h-7 rounded flex items-center justify-center transition-colors ${viewMode === 'grid' ? 'bg-panel shadow-sm text-textPrimary' : 'text-textTertiary hover:text-textSecondary'}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
</div>
{/* Mobile filter button */}
<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"
>
<FilterIcon />
Filtros
{activeFilterCount > 0 && (
<span className="bg-brand text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{activeFilterCount}
</span>
)}
</button>
</div>
</div>
</div>
</div>
{/* ── 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>
)}
{!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>
)}
{/* 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>
</main>
{/* Sidebar overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setSidebarOpen(false)}
/>
<div className="absolute left-0 top-0 bottom-0 w-[320px] max-w-[85vw] bg-panel border-r border-borderSubtle overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<span className="font-medium text-textPrimary">Filtros</span>
<button
onClick={() => setSidebarOpen(false)}
className="text-textTertiary hover:text-textPrimary transition-colors"
aria-label="Fechar filtros"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
<FilterSidebar
propertyTypes={propertyTypes}
amenities={amenities}
cities={cities}
neighborhoods={neighborhoods}
imobiliarias={imobiliarias}
filters={filters}
onChange={(f) => { handleFiltersChange(f); setSidebarOpen(false) }}
onClear={() => { handleClear(); setSidebarOpen(false) }}
catalogLoading={catalogLoading}
/>
</div>
</div>
)}
<ScrollToTopButton />
<Footer />
</div>
)
}
// ── Module-level helpers ──────────────────────────────────────────────────────
function hasActiveFilters(f: PropertyFilters): boolean {
return !!(
f.q?.trim() ||
f.listing_type ||
(f.subtype_ids?.length ?? 0) > 0 ||
f.imobiliaria_id != null ||
f.city_id != null ||
(f.neighborhood_ids?.length ?? 0) > 0 ||
f.price_min != null ||
f.price_max != null ||
f.bedrooms_min ||
f.bathrooms_min ||
f.parking_min ||
f.area_min != null ||
f.area_max != null ||
(f.amenity_ids?.length ?? 0) > 0
)
}
async function computeSuggestions(f: PropertyFilters): Promise<EmptyStateSuggestion[]> {
const relaxations: Array<{ label: string; filters: PropertyFilters; active: boolean }> = [
{
label: 'Remover filtro de bairro',
filters: { ...f, neighborhood_ids: undefined, page: 1 },
active: (f.neighborhood_ids?.length ?? 0) > 0,
},
{
label: 'Remover mínimo de quartos',
filters: { ...f, bedrooms_min: undefined, page: 1 },
active: !!f.bedrooms_min,
},
{
label: 'Ampliar faixa de preço',
filters: { ...f, price_max: undefined, page: 1 },
active: f.price_max != null,
},
{
label: 'Remover filtro de tipo',
filters: { ...f, subtype_ids: undefined, listing_type: undefined, page: 1 },
active: !!f.listing_type || (f.subtype_ids?.length ?? 0) > 0,
},
]
const active = relaxations.filter(r => r.active)
if (active.length === 0) return []
const results = await Promise.all(
active.map(r => getProperties({ ...r.filters, per_page: 1 }).then(d => ({ ...r, count: d.total })))
)
return results
.filter(r => r.count > 0)
.slice(0, 3)
.map(r => ({
label: r.label,
count: r.count,
onApply: () => { /* stub — parent drives filter changes */ },
}))
}

View file

@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import Footer from '../components/Footer';
import HeartButton from '../components/HeartButton';
import Navbar from '../components/Navbar';
import AmenitiesSection from '../components/PropertyDetail/AmenitiesSection';
import ContactSection from '../components/PropertyDetail/ContactSection';
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';
import PriceBox from '../components/PropertyDetail/PriceBox';
import PropertyDetailSkeleton from '../components/PropertyDetail/PropertyDetailSkeleton';
import StatsStrip from '../components/PropertyDetail/StatsStrip';
import { useComparison } from '../contexts/ComparisonContext';
import { getProperty } from '../services/properties';
import type { PropertyDetail } from '../types/property';
function ChevronRight() {
return (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function Breadcrumb({ property }: { property: PropertyDetail }) {
const city = property.city
const nbh = property.neighborhood
return (
<nav aria-label="Navegação estrutural" className="flex items-center flex-wrap gap-1.5 text-xs text-textQuaternary">
<Link to="/imoveis" className="hover:text-textSecondary transition-colors">
Imóveis
</Link>
{city && (
<>
<ChevronRight />
<Link
to={`/imoveis?city_id=${city.id}`}
className="hover:text-textSecondary transition-colors"
>
{city.name}
</Link>
</>
)}
{nbh && (
<>
<ChevronRight />
<Link
to={`/imoveis?city_id=${city?.id}&neighborhood_id=${nbh.id}`}
className="hover:text-textSecondary transition-colors"
>
{nbh.name}
</Link>
</>
)}
<ChevronRight />
<span className="text-textPrimary truncate max-w-[200px]">{property.title}</span>
</nav>
)
}
function NotFoundState() {
return (
<div className="flex flex-col items-center justify-center py-32 text-center">
<p className="text-textSecondary text-base mb-2">Imóvel não encontrado.</p>
<p className="text-textQuaternary text-sm mb-6">O imóvel que você está buscando não existe ou foi removido.</p>
<Link
to="/imoveis"
className="px-4 py-2 rounded-lg bg-brand-indigo text-white text-sm font-medium hover:bg-brand-indigo/90 transition-colors"
>
Ver todos os imóveis
</Link>
</div>
)
}
export default function PropertyDetailPage() {
const { slug } = useParams<{ slug: string }>()
const [property, setProperty] = useState<PropertyDetail | null>(null)
const [loading, setLoading] = useState(true)
const [notFound, setNotFound] = useState(false)
useEffect(() => {
if (!slug) return
setLoading(true)
setNotFound(false)
getProperty(slug)
.then(setProperty)
.catch(() => setNotFound(true))
.finally(() => setLoading(false))
}, [slug])
const { properties, isInComparison, add, remove } = useComparison();
const inComparison = property ? isInComparison(property.id) : false;
return (
<div className="min-h-screen bg-canvas text-textPrimary">
<Navbar />
<main className="pt-14">
<div className="max-w-[1200px] mx-auto px-6 py-8">
{loading ? (
<PropertyDetailSkeleton />
) : notFound || !property ? (
<NotFoundState />
) : (
<>
{/* Breadcrumb */}
<div className="mb-6">
<Breadcrumb property={property} />
</div>
{/* Title + code + HeartButton + Compare */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<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} />
</h1>
<div className="flex items-center flex-wrap gap-3">
{property.code && (
<span className="text-xs text-textQuaternary bg-panel border border-white/5 rounded-full px-2.5 py-1">
Cód. {property.code}
</span>
)}
{property.address && (
<span className="text-sm text-textTertiary">{property.address}</span>
)}
</div>
</div>
<button
onClick={() => inComparison ? remove(property.id) : add(property)}
className={`rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] transition ${inComparison
? 'bg-[#5e6ad2] text-white hover:bg-[#6872d8]'
: 'bg-white/[0.03] text-white/60 hover:bg-white/[0.08] hover:text-white'
}`}
>
{inComparison ? 'Remover da comparação' : 'Adicionar à comparação'}
</button>
</div>
{/* 2-column layout */}
<div className="flex flex-col lg:flex-row gap-8">
{/* Left — main content */}
<div className="flex-1 min-w-0 space-y-8">
{/* Carousel */}
<PhotoCarousel photos={property.photos} />
{/* Stats */}
<StatsStrip
bedrooms={property.bedrooms}
bathrooms={property.bathrooms}
parking_spots={property.parking_spots}
area_m2={property.area_m2}
/>
{/* Description */}
{property.description && (
<div>
<h2 className="text-sm font-medium text-textQuaternary uppercase tracking-[0.08em] mb-3">
Descrição
</h2>
<p className="text-sm text-textSecondary leading-relaxed whitespace-pre-line">
{property.description}
</p>
</div>
)}
{/* Amenities */}
{property.amenities.length > 0 && (
<div>
<h2 className="text-sm font-medium text-textQuaternary uppercase tracking-[0.08em] mb-4">
Comodidades
</h2>
<AmenitiesSection amenities={property.amenities} />
</div>
)}
{/* Mobile contact */}
<div className="lg:hidden">
<h2 className="text-sm font-medium text-textQuaternary uppercase tracking-[0.08em] mb-4">
Entrar em contato
</h2>
<ContactSection
slug={slug!}
propertyTitle={property.title}
propertyCode={property.code}
/>
</div>
</div>
{/* Right — sticky sidebar */}
<div className="hidden lg:flex flex-col gap-5 w-72 flex-shrink-0">
<PriceBox
price={property.price}
condo_fee={property.condo_fee}
listing_type={property.type}
/>
<div className="bg-panel border border-white/5 rounded-xl p-5">
<h2 className="text-sm font-medium text-textQuaternary uppercase tracking-[0.08em] mb-4">
Entrar em contato
</h2>
<ContactSection
slug={slug!}
propertyTitle={property.title}
propertyCode={property.code}
/>
</div>
</div>
</div>
{/* Mobile price (below content) */}
<div className="lg:hidden mt-8">
<PriceBox
price={property.price}
condo_fee={property.condo_fee}
listing_type={property.type}
/>
</div>
</>
)}
</div>
</main>
<Footer />
</div>
)
}

View file

@ -0,0 +1,220 @@
import { useState, type FormEvent } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
const inputCls = 'form-input'
function maskCpf(v: string) {
return v.replace(/\D/g, '').slice(0, 11)
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}
function maskPhone(v: string) {
const d = v.replace(/\D/g, '').slice(0, 11)
if (d.length <= 10)
return d.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3').trimEnd().replace(/-$/, '')
return d.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3').trimEnd().replace(/-$/, '')
}
function maskZip(v: string) {
return v.replace(/\D/g, '').slice(0, 8).replace(/(\d{5})(\d)/, '$1-$2')
}
export default function RegisterPage() {
const { register } = useAuth()
const navigate = useNavigate()
// Acesso
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
// Contato
const [phone, setPhone] = useState('')
const [whatsapp, setWhatsapp] = useState('')
const [cpf, setCpf] = useState('')
const [birthDate, setBirthDate] = useState('')
// Endereço
const [street, setStreet] = useState('')
const [number, setNumber] = useState('')
const [complement, setComplement] = useState('')
const [neighborhood, setNeighborhood] = useState('')
const [city, setCity] = useState('')
const [state, setState] = useState('')
const [zip, setZip] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('As senhas não coincidem.')
return
}
if (password.length < 8) {
setError('A senha deve ter pelo menos 8 caracteres.')
return
}
setLoading(true)
try {
await register({
name,
email,
password,
confirmPassword,
phone: phone || undefined,
whatsapp: whatsapp || undefined,
cpf: cpf || undefined,
birth_date: birthDate || undefined,
address_street: street || undefined,
address_number: number || undefined,
address_complement: complement || undefined,
address_neighborhood: neighborhood || undefined,
address_city: city || undefined,
address_state: state || undefined,
address_zip: zip || undefined,
})
navigate('/area-do-cliente', { replace: true })
} catch (err: unknown) {
const axiosErr = err as {
response?: { data?: { error?: string }; status?: number }
}
if (axiosErr.response?.status === 409) {
setError('Este e-mail já está cadastrado.')
} else {
setError('Erro ao criar conta. Tente novamente.')
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-canvas px-4 py-12 flex items-start justify-center">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<h1 className="text-2xl font-semibold text-textPrimary">Criar conta</h1>
<p className="mt-1 text-sm text-textSecondary">Acesse a área do cliente</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{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>
)}
{/* ── Acesso ── */}
<section className="form-section">
<span className="text-[11px] font-semibold text-textTertiary uppercase tracking-widest">Acesso</span>
<div className="space-y-1">
<label className="form-label">Nome <span className="text-red-400">*</span></label>
<input type="text" required value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="Seu nome completo" />
</div>
<div className="space-y-1">
<label className="form-label">E-mail <span className="text-red-400">*</span></label>
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} className={inputCls} placeholder="seu@email.com" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="form-label">Senha <span className="text-red-400">*</span></label>
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} className={inputCls} placeholder="Mínimo 8 caracteres" autoComplete="new-password" />
</div>
<div className="space-y-1">
<label className="form-label">Confirmar senha <span className="text-red-400">*</span></label>
<input type="password" required value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} className={inputCls} placeholder="••••••••" autoComplete="new-password" />
</div>
</div>
</section>
{/* ── Contato ── */}
<section className="form-section">
<span className="text-[11px] font-semibold text-textTertiary uppercase tracking-widest">Contato <span className="text-textQuaternary font-normal normal-case tracking-normal text-[10px]"> opcional</span></span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="form-label">Telefone</label>
<input value={phone} onChange={e => setPhone(maskPhone(e.target.value))} className={inputCls} placeholder="(00) 00000-0000" maxLength={15} />
</div>
<div className="space-y-1">
<label className="form-label">WhatsApp</label>
<input value={whatsapp} onChange={e => setWhatsapp(maskPhone(e.target.value))} className={inputCls} placeholder="(00) 00000-0000" maxLength={15} />
</div>
<div className="space-y-1">
<label className="form-label">CPF</label>
<input value={cpf} onChange={e => setCpf(maskCpf(e.target.value))} className={inputCls} placeholder="000.000.000-00" maxLength={14} />
</div>
<div className="space-y-1">
<label className="form-label">Data de nascimento</label>
<input type="date" value={birthDate} onChange={e => setBirthDate(e.target.value)} className={inputCls} />
</div>
</div>
</section>
{/* ── Endereço ── */}
<section className="form-section">
<span className="text-[11px] font-semibold text-textTertiary uppercase tracking-widest">Endereço <span className="text-textQuaternary font-normal normal-case tracking-normal text-[10px]"> opcional</span></span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1">
<label className="form-label">Logradouro</label>
<input value={street} onChange={e => setStreet(e.target.value)} className={inputCls} placeholder="Rua, Av., Alameda..." />
</div>
<div className="space-y-1">
<label className="form-label">Número</label>
<input value={number} onChange={e => setNumber(e.target.value)} className={inputCls} />
</div>
<div className="space-y-1">
<label className="form-label">Complemento</label>
<input value={complement} onChange={e => setComplement(e.target.value)} className={inputCls} placeholder="Apto, bloco..." />
</div>
<div className="space-y-1">
<label className="form-label">Bairro</label>
<input value={neighborhood} onChange={e => setNeighborhood(e.target.value)} className={inputCls} />
</div>
<div className="space-y-1">
<label className="form-label">CEP</label>
<input value={zip} onChange={e => setZip(maskZip(e.target.value))} className={inputCls} placeholder="00000-000" maxLength={9} />
</div>
<div className="sm:col-span-2 space-y-1">
<label className="form-label">Cidade</label>
<input value={city} onChange={e => setCity(e.target.value)} className={inputCls} />
</div>
<div className="space-y-1">
<label className="form-label">Estado (UF)</label>
<input value={state} onChange={e => setState(e.target.value.toUpperCase().slice(0, 2))} className={inputCls} placeholder="SP" maxLength={2} />
</div>
</div>
</section>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-brand py-2.5 text-sm font-medium text-white hover:bg-accentHover focus:outline-none disabled:opacity-50 transition-colors"
>
{loading ? 'Criando conta...' : 'Criar conta'}
</button>
<p className="text-center text-sm text-textTertiary">
tem conta?{' '}
<Link to="/login" className="text-accent hover:underline">
Entrar
</Link>
</p>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,388 @@
import { useEffect, useState } from 'react'
import { adminCreateAgent, adminDeleteAgent, adminGetAgents, adminUpdateAgent } from '../../services/agents'
import type { Agent, AgentFormData } from '../../types/agent'
const EMPTY_FORM: AgentFormData = {
name: '',
photo_url: '',
creci: '',
email: '',
phone: '',
bio: '',
is_active: true,
display_order: 0,
}
function getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join('')
}
interface AgentModalProps {
initial: AgentFormData
onSave: (data: AgentFormData) => Promise<void>
onClose: () => void
saving: boolean
title: string
}
function AgentModal({ initial, onSave, onClose, saving, title }: AgentModalProps) {
const [form, setForm] = useState<AgentFormData>(initial)
function set<K extends keyof AgentFormData>(key: K, value: AgentFormData[K]) {
setForm((prev) => ({ ...prev, [key]: value }))
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-panel border border-borderStandard rounded-2xl w-full max-w-[540px] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-borderSubtle">
<h2 className="text-textPrimary font-semibold text-base">{title}</h2>
<button onClick={onClose} className="text-textTertiary hover:text-textPrimary transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Nome */}
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">Nome *</label>
<input
type="text"
value={form.name}
onChange={(e) => set('name', e.target.value)}
className="w-full bg-canvas border-borderStandard rounded-lg px-3 py-2 text-textPrimary text-sm bg-surface focus:outline-none focus:border-accent/60 transition-colors"
placeholder="Nome completo"
/>
</div>
{/* CRECI + Ordem */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">CRECI *</label>
<input
type="text"
value={form.creci}
onChange={(e) => set('creci', e.target.value)}
className="w-full bg-canvas border-borderStandard rounded-lg px-3 py-2 text-textPrimary text-sm bg-surface focus:outline-none focus:border-accent/60 transition-colors"
placeholder="Ex: 196132F"
/>
</div>
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">Ordem</label>
<input
type="number"
value={form.display_order}
onChange={(e) => set('display_order', Number(e.target.value))}
className="w-full bg-canvas border-borderStandard rounded-lg px-3 py-2 text-textPrimary text-sm bg-surface focus:outline-none focus:border-accent/60 transition-colors"
min={0}
/>
</div>
</div>
{/* E-mail + Telefone */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">E-mail *</label>
<input
type="email"
value={form.email}
onChange={(e) => set('email', e.target.value)}
className="w-full bg-canvas border-borderStandard rounded-lg px-3 py-2 text-textPrimary text-sm bg-surface focus:outline-none focus:border-accent/60 transition-colors"
placeholder="email@exemplo.com"
/>
</div>
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">Telefone *</label>
<input
type="text"
value={form.phone}
onChange={(e) => set('phone', e.target.value)}
className="w-full bg-canvas border-borderStandard rounded-lg px-3 py-2 text-textPrimary text-sm bg-surface focus:outline-none focus:border-accent/60 transition-colors"
placeholder="(16) 99999-0000"
/>
</div>
</div>
{/* Foto URL */}
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">URL da foto</label>
<input
type="url"
value={form.photo_url}
onChange={(e) => set('photo_url', e.target.value)}
className="w-full bg-canvas border-borderStandard rounded-lg px-3 py-2 text-textPrimary text-sm bg-surface focus:outline-none focus:border-accent/60 transition-colors"
placeholder="https://..."
/>
</div>
{/* Bio */}
<div>
<label className="block text-textSecondary text-xs font-medium mb-1.5">Bio</label>
<textarea
value={form.bio}
onChange={(e) => set('bio', e.target.value)}
rows={3}
className="w-full bg-canvas form-input resize-none"
placeholder="Breve apresentação do corretor..."
/>
</div>
{/* Ativo */}
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
onClick={() => set('is_active', !form.is_active)}
className={`relative w-10 h-5 rounded-full transition-colors ${form.is_active ? 'bg-[#5e6ad2]' : 'bg-white/[0.1]'}`}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${form.is_active ? 'translate-x-5' : 'translate-x-0.5'}`}
/>
</div>
<span className="text-textSecondary text-sm">Ativo</span>
</label>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-borderSubtle">
<button
onClick={onClose}
disabled={saving}
className="px-4 py-2 text-sm text-textSecondary hover:text-textPrimary transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={() => onSave(form)}
disabled={saving}
className="px-5 py-2 bg-[#5e6ad2] hover:bg-[#6e7ae0] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving && <span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin" />}
Salvar
</button>
</div>
</div>
</div>
)
}
export default function AdminAgentsPage() {
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState<{ mode: 'create' | 'edit'; agent?: Agent } | null>(null)
const [saving, setSaving] = useState(false)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
async function load() {
setLoading(true)
try {
const data = await adminGetAgents()
setAgents(data)
} catch {
setError('Erro ao carregar corretores.')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
async function handleSave(form: AgentFormData) {
setSaving(true)
setError(null)
try {
const payload: AgentFormData = {
...form,
photo_url: form.photo_url.trim() || '',
bio: form.bio.trim() || '',
}
if (modal?.mode === 'edit' && modal.agent) {
await adminUpdateAgent(modal.agent.id, payload)
} else {
await adminCreateAgent(payload)
}
setModal(null)
await load()
} catch {
setError('Erro ao salvar corretor.')
} finally {
setSaving(false)
}
}
async function handleDelete(id: number) {
setDeleteId(id)
try {
await adminDeleteAgent(id)
setAgents((prev) => prev.filter((a) => a.id !== id))
} catch {
setError('Erro ao remover corretor.')
} finally {
setDeleteId(null)
}
}
const initialForModal: AgentFormData = modal?.agent
? {
name: modal.agent.name,
photo_url: modal.agent.photo_url ?? '',
creci: modal.agent.creci,
email: modal.agent.email,
phone: modal.agent.phone,
bio: modal.agent.bio ?? '',
is_active: modal.agent.is_active,
display_order: modal.agent.display_order,
}
: EMPTY_FORM
return (
<div className="p-6 md:p-8 max-w-[1100px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-textPrimary tracking-tight">Corretores</h1>
<p className="text-textSecondary text-sm mt-0.5">Gerencie a equipe de corretores exibida no site.</p>
</div>
<button
onClick={() => setModal({ mode: 'create' })}
className="flex items-center gap-2 px-4 py-2 bg-[#5e6ad2] hover:bg-[#6e7ae0] text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Adicionar corretor
</button>
</div>
{/* Error */}
{error && (
<div className="mb-4 px-4 py-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{/* Table */}
<div className="bg-panel border border-borderSubtle rounded-2xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-6 h-6 border-2 border-[#5e6ad2] border-t-transparent rounded-full animate-spin" />
</div>
) : agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-textSecondary text-sm">Nenhum corretor cadastrado.</p>
<button
onClick={() => setModal({ mode: 'create' })}
className="mt-4 text-[#5e6ad2] text-sm hover:underline"
>
Adicionar o primeiro corretor
</button>
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-borderSubtle">
<th className="text-left px-5 py-3 text-textTertiary font-medium text-xs uppercase tracking-wider">Corretor</th>
<th className="text-left px-5 py-3 text-textTertiary font-medium text-xs uppercase tracking-wider hidden md:table-cell">CRECI</th>
<th className="text-left px-5 py-3 text-textTertiary font-medium text-xs uppercase tracking-wider hidden lg:table-cell">Contato</th>
<th className="text-left px-5 py-3 text-textTertiary font-medium text-xs uppercase tracking-wider">Status</th>
<th className="text-right px-5 py-3 text-textTertiary font-medium text-xs uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{agents.map((agent) => (
<tr key={agent.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{agent.photo_url ? (
<img
src={agent.photo_url}
alt={agent.name}
className="w-9 h-9 rounded-full object-cover flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : (
<div className="w-9 h-9 rounded-full bg-[#5e6ad2]/20 flex items-center justify-center text-[#5e6ad2] text-xs font-semibold flex-shrink-0">
{getInitials(agent.name)}
</div>
)}
<div>
<p className="text-textPrimary font-medium leading-tight">{agent.name}</p>
<p className="text-textTertiary text-xs mt-0.5">Ordem: {agent.display_order}</p>
</div>
</div>
</td>
<td className="px-5 py-4 text-textSecondary hidden md:table-cell">{agent.creci}</td>
<td className="px-5 py-4 hidden lg:table-cell">
<div className="text-textSecondary text-xs space-y-0.5">
<p className="truncate max-w-[180px]">{agent.email}</p>
<p>{agent.phone}</p>
</div>
</td>
<td className="px-5 py-4">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${agent.is_active
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-white/[0.06] text-textTertiary'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${agent.is_active ? 'bg-emerald-400' : 'bg-text-tertiary'}`} />
{agent.is_active ? 'Ativo' : 'Inativo'}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setModal({ mode: 'edit', agent })}
className="p-1.5 text-textTertiary hover:text-textPrimary hover:bg-surface rounded-lg transition-colors"
title="Editar"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(agent.id)}
disabled={deleteId === agent.id}
className="p-1.5 text-textTertiary hover:text-red-400 hover:bg-red-500/[0.08] rounded-lg transition-colors disabled:opacity-40"
title="Desativar"
>
{deleteId === agent.id ? (
<span className="w-4 h-4 border border-current border-t-transparent rounded-full animate-spin block" />
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Modal */}
{modal && (
<AgentModal
initial={initialForModal}
onSave={handleSave}
onClose={() => setModal(null)}
saving={saving}
title={modal.mode === 'create' ? 'Novo corretor' : 'Editar corretor'}
/>
)}
</div>
)
}

View file

@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
interface Amenity { id: number; name: string; group: AmenityGroup; }
type AmenityGroup = 'caracteristica' | 'lazer' | 'condominio' | 'seguranca';
const GROUPS: { value: AmenityGroup; label: string }[] = [
{ value: 'caracteristica', label: 'Características' },
{ value: 'lazer', label: 'Lazer' },
{ value: 'condominio', label: 'Condomínio' },
{ value: 'seguranca', label: 'Segurança' },
];
export default function AdminAmenitiesPage() {
const [amenities, setAmenities] = useState<Amenity[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({ name: '', group: 'caracteristica' as AmenityGroup });
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null);
const loadAmenities = () => {
setLoading(true);
api.get('/admin/amenities').then(r => setAmenities(r.data)).finally(() => setLoading(false));
};
useEffect(() => { loadAmenities(); }, []);
const byGroup = (group: AmenityGroup) => amenities.filter(a => a.group === group);
const validate = () => {
const errs: Record<string, string> = {};
if (!form.name.trim()) errs.name = 'Nome obrigatório';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleAdd = async () => {
if (!validate()) return;
setSaving(true);
try {
await api.post('/admin/amenities', form);
setForm({ name: '', group: 'caracteristica' });
loadAmenities();
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number, name: string) => {
if (!confirm(`Excluir amenidade "${name}"? Será removida de todos os imóveis.`)) return;
setDeleting(id);
try {
await api.delete(`/admin/amenities/${id}`);
loadAmenities();
} finally {
setDeleting(null);
}
};
const inputCls = (err?: string) =>
`w-full rounded-lg border px-3 py-2 text-sm bg-canvas text-textPrimary outline-none focus:ring-2 focus:ring-brand/50 transition ${err ? 'border-red-500' : 'border-borderPrimary'}`;
return (
<div className="p-6 max-w-3xl mx-auto">
<h1 className="text-2xl font-bold text-textPrimary mb-6">Amenidades</h1>
{/* Formulário de nova amenidade */}
<div className="bg-panel rounded-xl border border-borderPrimary p-5 mb-6">
<h2 className="text-sm font-semibold text-textSecondary mb-4">Nova Amenidade</h2>
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-48">
<input
value={form.name}
onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
placeholder="Ex: Churrasqueira, Gerador…"
className={inputCls(errors.name)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
/>
{errors.name && <p className="text-xs text-red-400 mt-1">{errors.name}</p>}
</div>
<div>
<select
value={form.group}
onChange={e => setForm(p => ({ ...p, group: e.target.value as AmenityGroup }))}
className={inputCls()}
>
{GROUPS.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
</select>
</div>
<button
onClick={handleAdd}
disabled={saving}
className="px-4 py-2 rounded-lg bg-brand text-black text-sm font-semibold hover:bg-accentHover disabled:opacity-50 transition"
>
{saving ? 'Salvando…' : '+ Adicionar'}
</button>
</div>
</div>
{/* Lista agrupada */}
{loading ? (
<p className="text-textSecondary text-sm">Carregando</p>
) : (
<div className="flex flex-col gap-5">
{GROUPS.map(g => {
const items = byGroup(g.value);
return (
<div key={g.value} className="bg-panel rounded-xl border border-borderPrimary overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-borderSubtle">
<h3 className="text-sm font-semibold text-textPrimary">{g.label}</h3>
<span className="text-xs text-textTertiary bg-surface px-2 py-0.5 rounded-full">
{items.length} {items.length === 1 ? 'item' : 'itens'}
</span>
</div>
<div className="p-4">
{items.length === 0 ? (
<p className="text-xs text-textTertiary">Nenhuma amenidade neste grupo.</p>
) : (
<div className="flex flex-wrap gap-2">
{items.map(a => (
<div
key={a.id}
className="flex items-center gap-1.5 bg-surface border border-borderSubtle rounded-full px-3 py-1"
>
<span className="text-sm text-textPrimary">{a.name}</span>
<button
onClick={() => handleDelete(a.id, a.name)}
disabled={deleting === a.id}
className="text-textTertiary hover:text-red-400 transition ml-1 disabled:opacity-50"
title="Remover"
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,330 @@
import { useEffect, useState, useCallback } from 'react';
import api from '../../services/api';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyPoint {
date: string;
views: number;
}
interface Summary {
today: number;
this_week: number;
this_month: number;
period_total: number;
period_days: number;
series: DailyPoint[];
}
interface TopPage {
path: string;
views: number;
}
interface TopProperty {
property_id: string;
title: string;
cover: string | null;
views: number;
city: string | null;
neighborhood: string | null;
}
type Period = 7 | 30 | 90;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtNum(n: number): string {
return n.toLocaleString('pt-BR');
}
function friendlyPath(path: string): string {
if (path === '/' || path === '') return 'Home';
if (path.startsWith('/imoveis/')) return 'Detalhe de imóvel';
if (path === '/imoveis') return 'Listagem de imóveis';
if (path === '/sobre') return 'Sobre';
if (path === '/contato') return 'Contato';
if (path.startsWith('/api/v1/properties')) return 'API Imóveis';
return path;
}
// ─── SVG Sparkline ────────────────────────────────────────────────────────────
function Sparkline({ data }: { data: DailyPoint[] }) {
if (data.length < 2) {
return (
<div className="flex items-center justify-center h-24 text-textTertiary text-[13px]">
Dados insuficientes
</div>
);
}
const W = 560;
const H = 96;
const PAD = 8;
const maxV = Math.max(...data.map(d => d.views), 1);
const points = data.map((d, i) => {
const x = PAD + (i / (data.length - 1)) * (W - PAD * 2);
const y = PAD + (1 - d.views / maxV) * (H - PAD * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const polyline = points.join(' ');
// fill area under line
const first = points[0];
const last = points[points.length - 1];
const areaPath = `M ${first} L ${points.slice(1).join(' L ')} L ${last.split(',')[0]},${H} L ${first.split(',')[0]},${H} Z`;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full h-24"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="sparkGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#5e6ad2" stopOpacity="0.25" />
<stop offset="100%" stopColor="#5e6ad2" stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#sparkGrad)" />
<polyline
points={polyline}
fill="none"
stroke="#5e6ad2"
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
// ─── Metric Card ─────────────────────────────────────────────────────────────
function MetricCard({ label, value, sub }: { label: string; value: number; sub?: string }) {
return (
<div className="bg-surface border border-borderPrimary rounded-xl p-5 flex flex-col gap-1">
<p className="text-textTertiary text-[12px] uppercase tracking-wide font-medium">{label}</p>
<p className="text-textPrimary text-[28px] font-semibold tracking-tight">{fmtNum(value)}</p>
{sub && <p className="text-textTertiary text-[12px]">{sub}</p>}
</div>
);
}
// ─── Period Tabs ─────────────────────────────────────────────────────────────
function PeriodTabs({ value, onChange }: { value: Period; onChange: (p: Period) => void }) {
const opts: { label: string; v: Period }[] = [
{ label: '7 dias', v: 7 },
{ label: '30 dias', v: 30 },
{ label: '90 dias', v: 90 },
];
return (
<div className="flex items-center gap-1 bg-panel border border-borderPrimary rounded-lg p-1">
{opts.map(o => (
<button
key={o.v}
onClick={() => onChange(o.v)}
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${value === o.v
? 'bg-brand text-white'
: 'text-textTertiary hover:text-textSecondary'
}`}
>
{o.label}
</button>
))}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AdminAnalyticsPage() {
const [period, setPeriod] = useState<Period>(30);
const [summary, setSummary] = useState<Summary | null>(null);
const [topPages, setTopPages] = useState<TopPage[]>([]);
const [topProperties, setTopProperties] = useState<TopProperty[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAll = useCallback(async (days: Period) => {
setLoading(true);
setError(null);
try {
const params = { days };
const [sumRes, pagesRes, propsRes] = await Promise.all([
api.get('/admin/analytics/summary', { params }),
api.get('/admin/analytics/top-pages', { params }),
api.get('/admin/analytics/top-properties', { params }),
]);
setSummary(sumRes.data);
setTopPages(pagesRes.data);
setTopProperties(propsRes.data);
} catch {
setError('Erro ao carregar analytics.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchAll(period); }, [period, fetchAll]);
const maxPageViews = topPages.length > 0 ? Math.max(...topPages.map(p => p.views), 1) : 1;
const maxPropViews = topProperties.length > 0 ? Math.max(...topProperties.map(p => p.views), 1) : 1;
return (
<div className="p-6 lg:p-8 min-h-full bg-canvas max-w-[1400px] mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div>
<h1 className="text-textPrimary text-[22px] font-semibold tracking-tight">Analytics</h1>
<p className="text-textTertiary text-[13px] mt-0.5">Acompanhe os acessos ao site</p>
</div>
<PeriodTabs value={period} onChange={setPeriod} />
</div>
{error && (
<div className="mb-6 px-4 py-3 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 text-[13px]">
{error}
</div>
)}
{loading ? (
<div className="space-y-6 animate-pulse">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-surface border border-borderPrimary rounded-xl" />
))}
</div>
<div className="h-40 bg-surface border border-borderPrimary rounded-xl" />
<div className="grid lg:grid-cols-2 gap-6">
<div className="h-72 bg-surface border border-borderPrimary rounded-xl" />
<div className="h-72 bg-surface border border-borderPrimary rounded-xl" />
</div>
</div>
) : summary ? (
<div className="space-y-6">
{/* Metric cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Hoje" value={summary.today} />
<MetricCard label="Esta semana" value={summary.this_week} />
<MetricCard label="Este mês" value={summary.this_month} />
<MetricCard
label={`Últimos ${period} dias`}
value={summary.period_total}
sub={`total no período`}
/>
</div>
{/* Sparkline chart */}
<div className="bg-surface border border-borderPrimary rounded-xl p-5">
<p className="text-textSecondary text-[13px] font-medium mb-4">
Acessos por dia últimos {period} dias
</p>
<Sparkline data={summary.series} />
{summary.series.length > 0 && (
<div className="flex justify-between mt-2">
<span className="text-textTertiary text-[11px]">
{summary.series[0]?.date}
</span>
<span className="text-textTertiary text-[11px]">
{summary.series[summary.series.length - 1]?.date}
</span>
</div>
)}
</div>
{/* Tables */}
<div className="grid lg:grid-cols-2 gap-6">
{/* Top pages */}
<div className="bg-surface border border-borderPrimary rounded-xl p-5">
<p className="text-textSecondary text-[13px] font-medium mb-4">
Páginas mais acessadas
</p>
{topPages.length === 0 ? (
<p className="text-textTertiary text-[13px] py-6 text-center">Nenhum dado no período</p>
) : (
<div className="space-y-2">
{topPages.map((p, i) => (
<div key={p.path} className="flex items-center gap-3">
<span className="w-5 text-textTertiary text-[12px] text-right shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-textSecondary text-[13px] truncate" title={p.path}>
{friendlyPath(p.path)}
</p>
<div className="mt-1 h-1.5 rounded-full bg-panel overflow-hidden">
<div
className="h-full rounded-full bg-brand transition-all"
style={{ width: `${(p.views / maxPageViews) * 100}%` }}
/>
</div>
</div>
<span className="text-textTertiary text-[12px] shrink-0 w-14 text-right">
{fmtNum(p.views)}
</span>
</div>
))}
</div>
)}
</div>
{/* Top properties */}
<div className="bg-surface border border-borderPrimary rounded-xl p-5">
<p className="text-textSecondary text-[13px] font-medium mb-4">
Imóveis mais vistos
</p>
{topProperties.length === 0 ? (
<p className="text-textTertiary text-[13px] py-6 text-center">Nenhum dado no período</p>
) : (
<div className="space-y-3">
{topProperties.map((p, i) => (
<div key={p.property_id} className="flex items-center gap-3">
<span className="w-5 text-textTertiary text-[12px] text-right shrink-0">{i + 1}</span>
{p.cover ? (
<img
src={p.cover}
alt={p.title}
className="w-10 h-10 rounded-md object-cover shrink-0 bg-panel"
/>
) : (
<div className="w-10 h-10 rounded-md bg-panel shrink-0 flex items-center justify-center">
<svg className="w-5 h-5 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-textSecondary text-[13px] truncate font-medium" title={p.title}>
{p.title}
</p>
{(p.city || p.neighborhood) && (
<p className="text-textTertiary text-[12px] truncate">
{[p.neighborhood, p.city].filter(Boolean).join(', ')}
</p>
)}
<div className="mt-1 h-1.5 rounded-full bg-panel overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all"
style={{ width: `${(p.views / maxPropViews) * 100}%` }}
/>
</div>
</div>
<span className="text-textTertiary text-[12px] shrink-0 w-14 text-right">
{fmtNum(p.views)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
import BoletoForm from './BoletoForm';
interface Boleto {
id: string;
user_id: string;
property_id?: string;
description: string;
amount: number;
due_date: string;
status: string;
url?: string;
user?: { name: string };
property?: { title: string };
}
export default function AdminBoletosPage() {
const [boletos, setBoletos] = useState<Boleto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Boleto | null>(null);
const [removing, setRemoving] = useState<Boleto | null>(null);
const [actionLoading, setActionLoading] = useState(false);
function fetchBoletos() {
setLoading(true);
api.get('/admin/boletos')
.then(res => setBoletos(res.data))
.catch(() => setError('Erro ao carregar boletos'))
.finally(() => setLoading(false));
}
useEffect(() => {
fetchBoletos();
}, []);
function handleCreate(data: { user_id: string; property_id?: string; description: string; amount: number; due_date: string; url?: string }) {
setActionLoading(true);
api.post('/admin/boletos', data)
.then(() => {
setShowForm(false);
fetchBoletos();
})
.catch(() => setError('Erro ao criar boleto'))
.finally(() => setActionLoading(false));
}
function handleEdit(data: { user_id: string; property_id?: string; description: string; amount: number; due_date: string; url?: string }) {
if (!editing) return;
setActionLoading(true);
api.put(`/admin/boletos/${editing.id}`, data)
.then(() => {
setEditing(null);
setShowForm(false);
fetchBoletos();
})
.catch(() => setError('Erro ao editar boleto'))
.finally(() => setActionLoading(false));
}
function handleRemove() {
if (!removing) return;
setActionLoading(true);
api.delete(`/admin/boletos/${removing.id}`)
.then(() => {
setRemoving(null);
fetchBoletos();
})
.catch(() => setError('Erro ao remover boleto'))
.finally(() => setActionLoading(false));
}
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">
<h2 className="text-xl font-bold text-textPrimary">Boletos</h2>
<button
className="px-4 py-2 rounded bg-brand text-white font-medium hover:bg-accentHover transition-colors"
onClick={() => { setShowForm(true); setEditing(null); }}
>
+ Novo Boleto
</button>
</div>
{loading && <div className="text-textSecondary text-sm">Carregando...</div>}
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="overflow-x-auto rounded-xl border border-borderPrimary">
<table className="min-w-full bg-panel text-sm">
<thead>
<tr className="text-left text-textSecondary border-b border-borderSubtle">
<th className="py-3 px-4 font-medium">Cliente</th>
<th className="py-3 px-4 font-medium hidden sm:table-cell">Imóvel</th>
<th className="py-3 px-4 font-medium">Descrição</th>
<th className="py-3 px-4 font-medium">Valor</th>
<th className="py-3 px-4 font-medium hidden md:table-cell">Vencimento</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium">Ações</th>
</tr>
</thead>
<tbody>
{boletos.length === 0 && !loading && (
<tr>
<td colSpan={7} className="py-8 text-center text-textTertiary">
Nenhum boleto encontrado.
</td>
</tr>
)}
{boletos.map((b) => (
<tr key={b.id} className="border-t border-borderSubtle text-textPrimary hover:bg-surface/50 transition-colors">
<td className="py-3 px-4">{b.user?.name || '—'}</td>
<td className="py-3 px-4 hidden sm:table-cell text-textSecondary">{b.property?.title || '—'}</td>
<td className="py-3 px-4">{b.description}</td>
<td className="py-3 px-4 font-medium">R$ {b.amount.toLocaleString('pt-BR')}</td>
<td className="py-3 px-4 hidden md:table-cell text-textSecondary">{b.due_date}</td>
<td className="py-3 px-4">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${b.status === 'paid' ? 'bg-statusEmerald/10 text-statusEmerald' :
b.status === 'overdue' ? 'bg-red-500/10 text-red-400' :
'bg-brand/10 text-accent'
}`}>{b.status}</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<button
className="text-accent hover:text-accentHover text-xs font-medium transition-colors"
onClick={() => { setEditing(b); setShowForm(true); }}
>Editar</button>
<button
className="text-red-400 hover:text-red-300 text-xs font-medium transition-colors"
onClick={() => setRemoving(b)}
>Remover</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal de formulário */}
{(showForm || editing) && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-panel border border-borderPrimary rounded-xl shadow-lg p-6 w-full max-w-lg relative">
<BoletoForm
initial={editing ? editing : undefined}
onSubmit={editing ? handleEdit : handleCreate}
onCancel={() => { setShowForm(false); setEditing(null); }}
/>
{actionLoading && <div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-xl"><div className="loader" /></div>}
</div>
</div>
)}
{/* Modal de confirmação de remoção */}
{removing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-panel border border-borderPrimary rounded-xl shadow-lg p-6 w-full max-w-md">
<h3 className="text-textPrimary font-semibold mb-2">Remover boleto</h3>
<p className="text-textSecondary text-sm mb-4">
Tem certeza que deseja remover o boleto <span className="text-textPrimary font-medium">{removing.description}</span>?
</p>
<div className="flex gap-2 justify-end">
<button
className="px-4 py-2 rounded border border-borderPrimary text-textSecondary hover:text-textPrimary hover:border-borderSecondary text-sm transition-colors"
onClick={() => setRemoving(null)}
>Cancelar</button>
<button
className="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
onClick={handleRemove}
disabled={actionLoading}
>Remover</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,279 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
interface Neighborhood { id: number; name: string; city_id: number; slug: string; }
interface City { id: number; name: string; state: string; slug: string; neighborhoods?: Neighborhood[]; }
const STATES = [
'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG',
'PA', 'PB', 'PR', 'PE', 'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO',
];
export default function AdminCitiesPage() {
const [cities, setCities] = useState<City[]>([]);
const [expanded, setExpanded] = useState<number | null>(null);
const [neighborhoods, setNeighborhoods] = useState<Record<number, Neighborhood[]>>({});
const [loading, setLoading] = useState(true);
// city form
const [cityForm, setCityForm] = useState({ name: '', state: 'SP' });
const [editingCity, setEditingCity] = useState<City | null>(null);
const [cityErrors, setCityErrors] = useState<Record<string, string>>({});
// neighborhood form
const [nbForm, setNbForm] = useState<Record<number, string>>({});
const [editingNb, setEditingNb] = useState<Neighborhood | null>(null);
const [editNbName, setEditNbName] = useState('');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const loadCities = () => {
setLoading(true);
api.get('/admin/cities').then(r => {
setCities(r.data);
}).finally(() => setLoading(false));
};
const loadNeighborhoods = (cityId: number) => {
api.get('/admin/neighborhoods', { params: { city_id: cityId } }).then(r => {
setNeighborhoods(prev => ({ ...prev, [cityId]: r.data }));
});
};
useEffect(() => { loadCities(); }, []);
const handleExpand = (id: number) => {
if (expanded === id) { setExpanded(null); return; }
setExpanded(id);
if (!neighborhoods[id]) loadNeighborhoods(id);
};
const validateCity = () => {
const errs: Record<string, string> = {};
if (!cityForm.name.trim()) errs.name = 'Nome obrigatório';
if (!cityForm.state) errs.state = 'Estado obrigatório';
setCityErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSaveCity = async () => {
if (!validateCity()) return;
setSaving(true);
try {
if (editingCity) {
await api.put(`/admin/cities/${editingCity.id}`, cityForm);
} else {
await api.post('/admin/cities', cityForm);
}
setCityForm({ name: '', state: 'SP' });
setEditingCity(null);
loadCities();
} finally {
setSaving(false);
}
};
const handleEditCity = (c: City) => {
setEditingCity(c);
setCityForm({ name: c.name, state: c.state });
setCityErrors({});
};
const handleDeleteCity = async (id: number) => {
if (!confirm('Excluir cidade e todos os bairros?')) return;
setDeleting(`city-${id}`);
try {
await api.delete(`/admin/cities/${id}`);
loadCities();
setNeighborhoods(prev => { const n = { ...prev }; delete n[id]; return n; });
if (expanded === id) setExpanded(null);
} finally {
setDeleting(null);
}
};
const handleAddNeighborhood = async (cityId: number) => {
const name = (nbForm[cityId] || '').trim();
if (!name) return;
setSaving(true);
try {
await api.post('/admin/neighborhoods', { name, city_id: cityId });
setNbForm(prev => ({ ...prev, [cityId]: '' }));
loadNeighborhoods(cityId);
} finally {
setSaving(false);
}
};
const handleEditNb = (nb: Neighborhood) => {
setEditingNb(nb);
setEditNbName(nb.name);
};
const handleSaveNb = async () => {
if (!editingNb || !editNbName.trim()) return;
setSaving(true);
try {
await api.put(`/admin/neighborhoods/${editingNb.id}`, { name: editNbName });
setEditingNb(null);
loadNeighborhoods(editingNb.city_id);
} finally {
setSaving(false);
}
};
const handleDeleteNb = async (nb: Neighborhood) => {
if (!confirm(`Excluir bairro "${nb.name}"?`)) return;
setDeleting(`nb-${nb.id}`);
try {
await api.delete(`/admin/neighborhoods/${nb.id}`);
loadNeighborhoods(nb.city_id);
} finally {
setDeleting(null);
}
};
const inputCls = (err?: string) =>
`w-full rounded-lg border px-3 py-2 text-sm bg-canvas text-textPrimary outline-none focus:ring-2 focus:ring-brand/50 transition ${err ? 'border-red-500' : 'border-borderPrimary'}`;
return (
<div className="p-6 max-w-3xl mx-auto">
<h1 className="text-2xl font-bold text-textPrimary mb-6">Cidades e Bairros</h1>
{/* Formulário de cidade */}
<div className="bg-panel rounded-xl border border-borderPrimary p-5 mb-6">
<h2 className="text-sm font-semibold text-textSecondary mb-4">
{editingCity ? `Editar: ${editingCity.name}` : 'Nova Cidade'}
</h2>
<div className="flex gap-3 flex-wrap">
<div className="flex-1 min-w-40">
<input
value={cityForm.name}
onChange={e => setCityForm(p => ({ ...p, name: e.target.value }))}
placeholder="Nome da cidade"
className={inputCls(cityErrors.name)}
/>
{cityErrors.name && <p className="text-xs text-red-400 mt-1">{cityErrors.name}</p>}
</div>
<div>
<select
value={cityForm.state}
onChange={e => setCityForm(p => ({ ...p, state: e.target.value }))}
className={inputCls(cityErrors.state)}
>
{STATES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<button
onClick={handleSaveCity}
disabled={saving}
className="px-4 py-2 rounded-lg bg-brand text-black text-sm font-semibold hover:bg-accentHover disabled:opacity-50 transition"
>
{saving ? 'Salvando…' : editingCity ? 'Atualizar' : 'Adicionar'}
</button>
{editingCity && (
<button
onClick={() => { setEditingCity(null); setCityForm({ name: '', state: 'SP' }); setCityErrors({}); }}
className="px-4 py-2 rounded-lg border border-borderPrimary text-textSecondary text-sm hover:bg-surface transition"
>
Cancelar
</button>
)}
</div>
</div>
{/* Lista de cidades */}
{loading ? (
<p className="text-textSecondary text-sm">Carregando</p>
) : cities.length === 0 ? (
<p className="text-textSecondary text-sm">Nenhuma cidade cadastrada.</p>
) : (
<div className="flex flex-col gap-2">
{cities.map(c => (
<div key={c.id} className="bg-panel rounded-xl border border-borderPrimary overflow-hidden">
{/* Cabeçalho da cidade */}
<div className="flex items-center px-4 py-3 gap-3">
<button
onClick={() => handleExpand(c.id)}
className="flex-1 flex items-center gap-2 text-left"
>
<span className={`transition-transform ${expanded === c.id ? 'rotate-90' : ''}`}></span>
<span className="font-medium text-textPrimary">{c.name}</span>
<span className="text-xs text-textTertiary bg-surface px-2 py-0.5 rounded-full">{c.state}</span>
</button>
<button
onClick={() => handleEditCity(c)}
className="text-xs text-accent hover:underline"
>Editar</button>
<button
onClick={() => handleDeleteCity(c.id)}
disabled={deleting === `city-${c.id}`}
className="text-xs text-red-400 hover:underline disabled:opacity-50"
>Excluir</button>
</div>
{/* Bairros accordion */}
{expanded === c.id && (
<div className="border-t border-borderSubtle px-4 py-3 bg-surface">
<p className="text-xs font-semibold text-textSecondary mb-2">Bairros</p>
<div className="flex flex-col gap-1 mb-3">
{(neighborhoods[c.id] || []).map(nb => (
<div key={nb.id} className="flex items-center gap-2">
{editingNb?.id === nb.id ? (
<>
<input
value={editNbName}
onChange={e => setEditNbName(e.target.value)}
className="flex-1 rounded-lg border border-borderPrimary bg-canvas px-2 py-1 text-sm text-textPrimary outline-none focus:ring-2 focus:ring-brand/50"
onKeyDown={e => e.key === 'Enter' && handleSaveNb()}
autoFocus
/>
<button onClick={handleSaveNb} disabled={saving} className="text-xs text-brand hover:underline disabled:opacity-50">Salvar</button>
<button onClick={() => setEditingNb(null)} className="text-xs text-textSecondary hover:underline">Cancelar</button>
</>
) : (
<>
<span className="flex-1 text-sm text-textPrimary">{nb.name}</span>
<button onClick={() => handleEditNb(nb)} className="text-xs text-accent hover:underline">Editar</button>
<button
onClick={() => handleDeleteNb(nb)}
disabled={deleting === `nb-${nb.id}`}
className="text-xs text-red-400 hover:underline disabled:opacity-50"
>Excluir</button>
</>
)}
</div>
))}
{(neighborhoods[c.id] || []).length === 0 && (
<p className="text-xs text-textTertiary">Nenhum bairro.</p>
)}
</div>
{/* Adicionar bairro */}
<div className="flex gap-2">
<input
value={nbForm[c.id] || ''}
onChange={e => setNbForm(p => ({ ...p, [c.id]: e.target.value }))}
placeholder="Novo bairro…"
className="flex-1 rounded-lg border border-borderPrimary bg-canvas px-2 py-1 text-sm text-textPrimary outline-none focus:ring-2 focus:ring-brand/50"
onKeyDown={e => e.key === 'Enter' && handleAddNeighborhood(c.id)}
/>
<button
onClick={() => handleAddNeighborhood(c.id)}
disabled={saving}
className="px-3 py-1 rounded-lg bg-brand text-black text-xs font-semibold hover:bg-accentHover disabled:opacity-50 transition"
>
+ Adicionar
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
import ClienteForm from './ClienteForm';
export interface ClientUser {
id: string;
name: string;
email: string;
role: string;
created_at: string;
phone?: string;
whatsapp?: string;
cpf?: string;
birth_date?: string;
address_street?: string;
address_number?: string;
address_complement?: string;
address_neighborhood?: string;
address_city?: string;
address_state?: string;
address_zip?: string;
notes?: string;
}
function Initials({ name }: { name: string }) {
const parts = name.trim().split(/\s+/);
const letters = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: name.slice(0, 2);
return (
<div className="w-9 h-9 rounded-full bg-brand flex items-center justify-center text-white text-sm font-bold uppercase shrink-0">
{letters}
</div>
);
}
export default function AdminClientesPage() {
const [clientes, setClientes] = useState<ClientUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<ClientUser | null>(null);
const [removing, setRemoving] = useState<ClientUser | null>(null);
const [actionLoading, setActionLoading] = useState(false);
function fetchClientes() {
setLoading(true);
api.get('/admin/client-users')
.then(res => setClientes(res.data))
.catch(() => setError('Erro ao carregar clientes'))
.finally(() => setLoading(false));
}
useEffect(() => { fetchClientes(); }, []);
function handleCreate(data: Omit<ClientUser, 'id' | 'created_at'> & { password?: string }) {
setActionLoading(true);
api.post('/admin/client-users', data)
.then(() => { setShowForm(false); fetchClientes(); })
.catch(() => setError('Erro ao criar cliente'))
.finally(() => setActionLoading(false));
}
function handleEdit(data: Omit<ClientUser, 'id' | 'created_at'> & { password?: string }) {
if (!editing) return;
setActionLoading(true);
api.put(`/admin/client-users/${editing.id}`, data)
.then(() => { setEditing(null); setShowForm(false); fetchClientes(); })
.catch(() => setError('Erro ao editar cliente'))
.finally(() => setActionLoading(false));
}
function handleRemove() {
if (!removing) return;
setActionLoading(true);
api.delete(`/admin/client-users/${removing.id}`)
.then(() => { setRemoving(null); fetchClientes(); })
.catch(() => setError('Erro ao remover cliente'))
.finally(() => setActionLoading(false));
}
const q = search.toLowerCase();
const filtered = clientes.filter(c =>
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
(c.phone || '').includes(q) ||
(c.cpf || '').includes(q)
);
if (showForm || editing) {
return (
<ClienteForm
initial={editing ?? undefined}
onSubmit={editing ? handleEdit : handleCreate}
onCancel={() => { setShowForm(false); setEditing(null); }}
isEdit={!!editing}
loading={actionLoading}
/>
);
}
return (
<div className="p-6 md:p-8">
{/* Cabeçalho */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<h2 className="text-xl font-bold text-textPrimary">Clientes cadastrados</h2>
<button
className="px-4 py-2 rounded bg-brand text-white font-medium hover:bg-accentHover transition-colors"
onClick={() => { setShowForm(true); setEditing(null); }}
>
+ Novo Cliente
</button>
</div>
{/* Busca */}
<input
type="search"
placeholder="Buscar por nome, e-mail, telefone ou CPF..."
className="w-full max-w-md mb-4 rounded px-3 py-2 bg-surface text-textPrimary border border-borderSubtle focus:outline-none focus:border-brand text-sm"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{loading && <div className="text-textSecondary">Carregando...</div>}
{error && <div className="text-red-400">{error}</div>}
{/* Tabela */}
{!loading && (
<div className="overflow-x-auto rounded shadow">
<table className="min-w-full bg-panel text-sm">
<thead>
<tr className="text-left text-textSecondary border-b border-borderSubtle">
<th className="py-3 px-4 font-medium">Cliente</th>
<th className="py-3 px-4 font-medium hidden sm:table-cell">Contato</th>
<th className="py-3 px-4 font-medium hidden md:table-cell">CPF</th>
<th className="py-3 px-4 font-medium hidden lg:table-cell">Endereço</th>
<th className="py-3 px-4 font-medium hidden sm:table-cell">Tipo</th>
<th className="py-3 px-4 font-medium hidden md:table-cell">Cadastro</th>
<th className="py-3 px-4 font-medium">Ações</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="py-8 text-center text-textTertiary">
Nenhum cliente encontrado.
</td>
</tr>
)}
{filtered.map(c => {
const addressParts = [c.address_street, c.address_number, c.address_city, c.address_state].filter(Boolean);
const addressSummary = addressParts.length > 0 ? addressParts.join(', ') : '—';
const createdAt = c.created_at
? new Date(c.created_at).toLocaleDateString('pt-BR')
: '—';
return (
<tr key={c.id} className="border-t border-borderSubtle text-textPrimary hover:bg-surface/50 transition-colors">
{/* Cliente */}
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<Initials name={c.name} />
<div>
<div className="font-medium">{c.name}</div>
<div className="text-textSecondary text-xs">{c.email}</div>
</div>
</div>
</td>
{/* Contato */}
<td className="py-3 px-4 hidden sm:table-cell">
<div className="space-y-0.5">
{c.phone && (
<a href={`tel:${c.phone}`} className="block text-textSecondary text-xs hover:text-accent">
📞 {c.phone}
</a>
)}
{c.whatsapp && (
<a
href={`https://wa.me/55${c.whatsapp.replace(/\D/g, '')}`}
target="_blank"
rel="noreferrer"
className="block text-green-400 text-xs hover:underline"
>
WhatsApp
</a>
)}
{!c.phone && !c.whatsapp && <span className="text-textTertiary text-xs"></span>}
</div>
</td>
{/* CPF */}
<td className="py-3 px-4 hidden md:table-cell text-textSecondary text-xs">
{c.cpf || '—'}
</td>
{/* Endereço */}
<td className="py-3 px-4 hidden lg:table-cell text-textSecondary text-xs max-w-[200px] truncate" title={addressSummary}>
{addressSummary}
</td>
{/* Tipo */}
<td className="py-3 px-4 hidden sm:table-cell">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${c.role === 'admin' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-brand/20 text-accent'}`}>
{c.role === 'admin' ? 'Admin' : 'Cliente'}
</span>
</td>
{/* Cadastro */}
<td className="py-3 px-4 hidden md:table-cell text-textSecondary text-xs">
{createdAt}
</td>
{/* Ações */}
<td className="py-3 px-4">
<div className="flex gap-3">
<button
className="text-blue-400 hover:underline text-xs"
onClick={() => { setEditing(c); setShowForm(true); }}
>
Editar
</button>
<button
className="text-red-400 hover:underline text-xs"
onClick={() => setRemoving(c)}
>
Remover
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Modal de confirmação de remoção */}
{removing && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-panel rounded shadow-lg p-6 w-full max-w-md text-textPrimary">
<div className="mb-4">
Tem certeza que deseja remover o cliente <b>{removing.name}</b>?
</div>
<div className="flex gap-2 justify-end">
<button
className="px-4 py-2 rounded bg-surface text-textPrimary"
onClick={() => setRemoving(null)}
disabled={actionLoading}
>
Cancelar
</button>
<button
className="px-4 py-2 rounded bg-red-600 text-white"
onClick={handleRemove}
disabled={actionLoading}
>
{actionLoading ? 'Removendo...' : 'Remover'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
interface Favorito {
id: string;
user_id: string;
property_id?: string;
created_at: string;
user_name?: string;
property_title?: string;
}
export default function AdminFavoritosPage() {
const [favoritos, setFavoritos] = useState<Favorito[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [removing, setRemoving] = useState<Favorito | null>(null);
const [actionLoading, setActionLoading] = useState(false);
function fetchFavoritos() {
setLoading(true);
api.get('/admin/favoritos')
.then(res => setFavoritos(res.data))
.catch(() => setError('Erro ao carregar favoritos'))
.finally(() => setLoading(false));
}
useEffect(() => { fetchFavoritos(); }, []);
function handleRemove() {
if (!removing) return;
setActionLoading(true);
api.delete(`/admin/favoritos/${removing.id}`)
.then(() => { setRemoving(null); fetchFavoritos(); })
.catch(() => setError('Erro ao remover favorito'))
.finally(() => setActionLoading(false));
}
return (
<div className="p-6 md:p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-textPrimary">Favoritos</h2>
</div>
{loading && <div className="text-textSecondary text-sm">Carregando...</div>}
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="overflow-x-auto rounded-xl border border-borderPrimary">
<table className="min-w-full bg-panel text-sm">
<thead>
<tr className="text-left text-textSecondary border-b border-borderSubtle">
<th className="py-3 px-4 font-medium">Cliente</th>
<th className="py-3 px-4 font-medium hidden sm:table-cell">Imóvel</th>
<th className="py-3 px-4 font-medium hidden md:table-cell">Adicionado em</th>
<th className="py-3 px-4 font-medium">Ações</th>
</tr>
</thead>
<tbody>
{favoritos.length === 0 && !loading && (
<tr>
<td colSpan={4} className="py-8 text-center text-textTertiary">
Nenhum favorito encontrado.
</td>
</tr>
)}
{favoritos.map(f => (
<tr key={f.id} className="border-t border-borderSubtle text-textPrimary hover:bg-surface/50 transition-colors">
<td className="py-3 px-4">{f.user_name || f.user_id || '—'}</td>
<td className="py-3 px-4 hidden sm:table-cell text-textSecondary">{f.property_title || '—'}</td>
<td className="py-3 px-4 hidden md:table-cell text-textSecondary">{f.created_at.slice(0, 10)}</td>
<td className="py-3 px-4">
<button
className="text-red-400 hover:text-red-300 text-xs font-medium transition-colors"
onClick={() => setRemoving(f)}
>Remover</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal remoção */}
{removing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-panel border border-borderPrimary rounded-xl shadow-lg p-6 w-full max-w-md">
<h3 className="text-textPrimary font-semibold mb-2">Remover favorito</h3>
<p className="text-textSecondary text-sm mb-4">
Tem certeza que deseja remover o favorito de{' '}
<span className="text-textPrimary font-medium">{removing.user_name || removing.user_id}</span>
{' '}para o imóvel{' '}
<span className="text-textPrimary font-medium">{removing.property_title || '—'}</span>?
</p>
<div className="flex gap-2 justify-end">
<button
className="px-4 py-2 rounded border border-borderPrimary text-textSecondary hover:text-textPrimary hover:border-borderSecondary text-sm transition-colors"
onClick={() => setRemoving(null)}
>Cancelar</button>
<button
className="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
onClick={handleRemove}
disabled={actionLoading}
>Remover</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,549 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import api from '../../services/api';
import PropertyForm, { type PropertyFormData } from './PropertyForm';
interface Photo {
url: string;
alt_text: string;
display_order: number;
}
interface Property {
id: string;
title: string;
code: string | null;
address: string | null;
price: number;
condo_fee: number | null;
iptu_anual: number | null;
type: string;
bedrooms: number;
bathrooms: number;
parking_spots: number;
parking_spots_covered: number;
area_m2: number;
is_active: boolean;
is_featured: boolean;
city_id: number | null;
neighborhood_id: number | null;
city_name: string | null;
neighborhood_name: string | null;
description: string | null;
photos: Photo[];
amenity_ids: number[];
}
interface PaginatedResponse {
items: Property[];
total: number;
page: number;
per_page: number;
pages: number;
}
interface CityOption {
id: number;
name: string;
state: string;
}
interface NeighborhoodOption {
id: number;
name: string;
city_id: number;
}
// ─── Carrossel de imagens ──────────────────────────────────────────────────
function PropertyCarousel({ photos, title }: { photos: Photo[]; title: string }) {
const [idx, setIdx] = useState(0);
if (!photos.length) {
return (
<div className="w-full aspect-[4/3] bg-panel flex items-center justify-center rounded-t-xl">
<svg className="w-12 h-12 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14M14 8h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
);
}
function prev(e: React.MouseEvent) {
e.stopPropagation();
setIdx(i => (i === 0 ? photos.length - 1 : i - 1));
}
function next(e: React.MouseEvent) {
e.stopPropagation();
setIdx(i => (i === photos.length - 1 ? 0 : i + 1));
}
return (
<div className="relative w-full aspect-[4/3] overflow-hidden rounded-t-xl bg-panel group">
<img
src={photos[idx].url}
alt={photos[idx].alt_text || title}
className="w-full h-full object-cover transition-opacity duration-300"
/>
{photos.length > 1 && (
<>
<button
onClick={prev}
className="absolute left-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70"
aria-label="Foto anterior"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={next}
className="absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70"
aria-label="Próxima foto"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<span className="absolute bottom-2 right-2 text-[11px] font-medium bg-black/60 text-white px-2 py-0.5 rounded-full">
{idx + 1}/{photos.length}
</span>
</>
)}
</div>
);
}
// ─── Card de imóvel ────────────────────────────────────────────────────────
function PropertyCard({
property,
onEdit,
onRemove,
}: {
property: Property;
onEdit: (p: Property) => void;
onRemove: (p: Property) => void;
}) {
return (
<div className="h-full bg-surface border border-borderPrimary rounded-xl flex flex-col overflow-hidden hover:border-borderSecondary transition-colors shadow-card hover:shadow-card-hover">
<PropertyCarousel photos={property.photos} title={property.title} />
<div className="flex flex-col flex-1 p-4 gap-3">
{/* título + badges */}
<div className="flex items-start justify-between gap-2">
<h3 className="text-textPrimary font-medium text-[15px] leading-snug line-clamp-2 flex-1">
{property.title}
</h3>
<div className="flex flex-col items-end gap-1 shrink-0">
<span className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${property.is_active ? 'bg-statusEmerald/10 text-statusEmerald' : 'bg-red-500/10 text-red-400'}`}>
{property.is_active ? 'Ativo' : 'Inativo'}
</span>
{property.is_featured && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-brand/10 text-accent">
Destaque
</span>
)}
</div>
</div>
{/* localização */}
{(property.city_name || property.neighborhood_name) && (
<p className="text-textTertiary text-[13px] flex items-center gap-1">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{[property.neighborhood_name, property.city_name].filter(Boolean).join(', ')}
</p>
)}
{/* atributos */}
<div className="flex items-center gap-3 text-[13px] text-textSecondary border-t border-borderSubtle pt-3">
<span className="flex items-center gap-1" title="Quartos">
<svg className="w-3.5 h-3.5 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{property.bedrooms} qto{property.bedrooms !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1" title="Banheiros">
<svg className="w-3.5 h-3.5 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{property.bathrooms} bnh
</span>
<span className="flex items-center gap-1" title="Vagas">
<svg className="w-3.5 h-3.5 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 17H3a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h11l4 4v4a2 2 0 0 1-2 2h-1" />
<circle cx="7" cy="17" r="2" />
<circle cx="17" cy="17" r="2" />
</svg>
{property.parking_spots}
</span>
<span className="ml-auto text-textTertiary">{property.area_m2} m²</span>
</div>
{/* preço + tipo */}
<div className="flex items-center justify-between">
<div>
<p className="text-textPrimary font-semibold text-[15px] tracking-tight">
R$ {Number(property.price).toLocaleString('pt-BR')}
</p>
<p className="text-textTertiary text-[12px] uppercase tracking-wide">
{property.type === 'venda' ? 'Venda' : 'Aluguel'}
{property.code && ` · ${property.code}`}
</p>
</div>
</div>
{/* ações */}
<div className="flex gap-2 pt-1 border-t border-borderSubtle mt-auto">
<button
onClick={() => onEdit(property)}
className="flex-1 py-1.5 rounded-md bg-panel border border-borderPrimary text-textSecondary text-[13px] font-medium hover:border-borderSecondary hover:text-textPrimary transition-colors"
>
Editar
</button>
<button
onClick={() => onRemove(property)}
className="flex-1 py-1.5 rounded-md bg-red-500/5 border border-red-500/20 text-red-400 text-[13px] font-medium hover:bg-red-500/10 transition-colors"
>
Remover
</button>
</div>
</div>
</div>
);
}
// ─── Paginação ────────────────────────────────────────────────────────────
function Pagination({
page,
pages,
total,
perPage,
onPage,
}: {
page: number;
pages: number;
total: number;
perPage: number;
onPage: (p: number) => void;
}) {
if (pages <= 1) return null;
const from = (page - 1) * perPage + 1;
const to = Math.min(page * perPage, total);
return (
<div className="flex items-center justify-between mt-8 pt-4 border-t border-borderSubtle">
<p className="text-textTertiary text-[13px]">
Mostrando <span className="text-textSecondary">{from}{to}</span> de <span className="text-textSecondary">{total}</span> imóveis
</p>
<div className="flex items-center gap-1">
<button
onClick={() => onPage(page - 1)}
disabled={page === 1}
className="px-3 py-1.5 rounded-md border border-borderPrimary text-textSecondary text-[13px] disabled:opacity-30 hover:border-borderSecondary hover:text-textPrimary transition-colors"
>
Anterior
</button>
{Array.from({ length: Math.min(5, pages) }, (_, i) => {
const p = Math.max(1, Math.min(pages - 4, page - 2)) + i;
return (
<button
key={p}
onClick={() => onPage(p)}
className={`w-8 h-8 rounded-md text-[13px] border transition-colors ${p === page
? 'bg-brand border-brand text-white'
: 'border-borderPrimary text-textSecondary hover:border-borderSecondary hover:text-textPrimary'
}`}
>
{p}
</button>
);
})}
<button
onClick={() => onPage(page + 1)}
disabled={page === pages}
className="px-3 py-1.5 rounded-md border border-borderPrimary text-textSecondary text-[13px] disabled:opacity-30 hover:border-borderSecondary hover:text-textPrimary transition-colors"
>
Próxima
</button>
</div>
</div>
);
}
// ─── Página principal ──────────────────────────────────────────────────────
export default function AdminPropertiesPage() {
const [data, setData] = useState<PaginatedResponse>({ items: [], total: 0, page: 1, per_page: 16, pages: 1 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filtros
const [search, setSearch] = useState('');
const [cityId, setCityId] = useState<number | ''>('');
const [neighborhoodId, setNeighborhoodId] = useState<number | ''>('');
const [page, setPage] = useState(1);
// opções de seletor
const [cities, setCities] = useState<CityOption[]>([]);
const [neighborhoods, setNeighborhoods] = useState<NeighborhoodOption[]>([]);
// form/modal
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Property | null>(null);
const [removing, setRemoving] = useState<Property | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// carrega cidades uma vez
useEffect(() => {
api.get('/admin/cities').then(res => setCities(res.data)).catch(() => null);
}, []);
// carrega bairros quando cidade muda
useEffect(() => {
setNeighborhoodId('');
if (!cityId) { setNeighborhoods([]); return; }
api.get('/admin/neighborhoods', { params: { city_id: cityId } })
.then(res => setNeighborhoods(res.data))
.catch(() => setNeighborhoods([]));
}, [cityId]);
const fetchProperties = useCallback((pg: number, q: string, cid: number | '', nid: number | '') => {
setLoading(true);
const params: Record<string, unknown> = { page: pg, per_page: 16 };
if (q) params.q = q;
if (cid) params.city_id = cid;
if (nid) params.neighborhood_id = nid;
api.get('/admin/properties', { params })
.then(res => setData(res.data))
.catch(() => setError('Erro ao carregar imóveis'))
.finally(() => setLoading(false));
}, []);
// dispara busca com debounce no campo de texto
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
fetchProperties(1, search, cityId, neighborhoodId);
}, 350);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [search, cityId, neighborhoodId, fetchProperties]);
// muda de página
useEffect(() => {
fetchProperties(page, search, cityId, neighborhoodId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
function handleCreate(formData: PropertyFormData) {
setActionLoading(true);
api.post('/admin/properties', {
...formData,
price: Number(formData.price),
condo_fee: formData.condo_fee ? Number(formData.condo_fee) : null,
iptu_anual: formData.iptu_anual ? Number(formData.iptu_anual) : null,
})
.then(() => { setShowForm(false); fetchProperties(page, search, cityId, neighborhoodId); })
.catch(() => setError('Erro ao criar imóvel'))
.finally(() => setActionLoading(false));
}
function handleEdit(formData: PropertyFormData) {
if (!editing) return;
setActionLoading(true);
api.put(`/admin/properties/${editing.id}`, {
...formData,
price: Number(formData.price),
condo_fee: formData.condo_fee ? Number(formData.condo_fee) : null,
iptu_anual: formData.iptu_anual ? Number(formData.iptu_anual) : null,
})
.then(() => { setEditing(null); setShowForm(false); fetchProperties(page, search, cityId, neighborhoodId); })
.catch(() => setError('Erro ao editar imóvel'))
.finally(() => setActionLoading(false));
}
function handleRemove() {
if (!removing) return;
setActionLoading(true);
api.delete(`/admin/properties/${removing.id}`)
.then(() => { setRemoving(null); fetchProperties(page, search, cityId, neighborhoodId); })
.catch(() => setError('Erro ao remover imóvel'))
.finally(() => setActionLoading(false));
}
return (
<div className="p-6 lg:p-8 min-h-full bg-canvas max-w-[1400px] mx-auto">
{/* cabeçalho */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-textPrimary text-[22px] font-semibold tracking-tight">Imóveis</h1>
<p className="text-textTertiary text-[13px] mt-0.5">
{data.total} imóvel{data.total !== 1 ? 's' : ''} cadastrado{data.total !== 1 ? 's' : ''}
</p>
</div>
<button
onClick={() => { setShowForm(true); setEditing(null); }}
className="px-4 py-2 rounded-md bg-brand text-white text-[13px] font-medium hover:bg-accentHover transition-colors"
>
+ Novo Imóvel
</button>
</div>
{/* barra de filtros */}
<div className="flex flex-wrap gap-3 mb-6">
{/* busca */}
<div className="relative flex-1 min-w-[200px]">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-textTertiary pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Buscar por título ou código..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-md bg-panel border border-borderPrimary text-textSecondary placeholder:text-textTertiary text-[13px] focus:outline-none focus:border-accent focus:shadow-focus transition-colors"
/>
</div>
{/* cidade */}
<select
value={cityId}
onChange={e => { setCityId(e.target.value ? Number(e.target.value) : ''); setPage(1); }}
className="px-3 py-2 rounded-md bg-panel border border-borderPrimary text-textSecondary text-[13px] focus:outline-none focus:border-accent focus:shadow-focus transition-colors min-w-[160px]"
>
<option value="">Todas as cidades</option>
{cities.map(c => (
<option key={c.id} value={c.id}>{c.name}/{c.state}</option>
))}
</select>
{/* bairro */}
<select
value={neighborhoodId}
onChange={e => { setNeighborhoodId(e.target.value ? Number(e.target.value) : ''); setPage(1); }}
disabled={!cityId || neighborhoods.length === 0}
className="px-3 py-2 rounded-md bg-panel border border-borderPrimary text-textSecondary text-[13px] focus:outline-none focus:border-accent focus:shadow-focus transition-colors min-w-[160px] disabled:opacity-40"
>
<option value="">Todos os bairros</option>
{neighborhoods.map(n => (
<option key={n.id} value={n.id}>{n.name}</option>
))}
</select>
{/* limpar filtros */}
{(search || cityId || neighborhoodId) && (
<button
onClick={() => { setSearch(''); setCityId(''); setNeighborhoodId(''); setPage(1); }}
className="px-3 py-2 rounded-md border border-borderPrimary text-textTertiary text-[13px] hover:text-textSecondary hover:border-borderSecondary transition-colors"
>
Limpar
</button>
)}
</div>
{error && (
<div className="mb-4 px-4 py-3 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 text-[13px]">
{error}
</div>
)}
{/* grid de cards */}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className="bg-surface border border-borderPrimary rounded-xl overflow-hidden animate-pulse">
<div className="aspect-[4/3] bg-panel" />
<div className="p-4 space-y-3">
<div className="h-4 bg-panel rounded w-3/4" />
<div className="h-3 bg-panel rounded w-1/2" />
<div className="h-3 bg-panel rounded w-full" />
</div>
</div>
))}
</div>
) : data.items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<svg className="w-16 h-16 text-textTertiary mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p className="text-textSecondary font-medium">Nenhum imóvel encontrado</p>
<p className="text-textTertiary text-[13px] mt-1">Tente ajustar os filtros ou cadastre um novo imóvel.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-stretch">
{data.items.map(p => (
<PropertyCard
key={p.id}
property={p}
onEdit={prop => { setEditing(prop); setShowForm(true); }}
onRemove={setRemoving}
/>
))}
</div>
)}
<Pagination
page={data.page}
pages={data.pages}
total={data.total}
perPage={data.per_page}
onPage={p => setPage(p)}
/>
{/* Formulário full-screen */}
{(showForm || editing) && (
<PropertyForm
initial={editing ? {
...editing,
price: String(editing.price),
condo_fee: editing.condo_fee ? String(editing.condo_fee) : '',
iptu_anual: editing.iptu_anual ? String(editing.iptu_anual) : '',
city_id: editing.city_id ?? '',
neighborhood_id: editing.neighborhood_id ?? '',
code: editing.code ?? '',
description: editing.description ?? '',
amenity_ids: editing.amenity_ids ?? [],
} : undefined}
onSubmit={editing ? handleEdit : handleCreate}
onCancel={() => { setShowForm(false); setEditing(null); }}
isLoading={actionLoading}
/>
)}
{/* Modal de confirmação de remoção */}
{removing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-panel border border-borderPrimary rounded-xl shadow-card w-full max-w-md p-6">
<h3 className="text-textPrimary font-semibold text-[15px] mb-2">Confirmar remoção</h3>
<p className="text-textSecondary text-[13px] mb-6">
Tem certeza que deseja remover o imóvel <strong className="text-textPrimary">{removing.title}</strong>? Esta ação não pode ser desfeita.
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setRemoving(null)}
className="px-4 py-2 rounded-md border border-borderPrimary text-textSecondary text-[13px] hover:border-borderSecondary hover:text-textPrimary transition-colors"
>
Cancelar
</button>
<button
onClick={handleRemove}
disabled={actionLoading}
className="px-4 py-2 rounded-md bg-red-600 text-white text-[13px] font-medium hover:bg-red-500 disabled:opacity-50 transition-colors"
>
{actionLoading ? 'Removendo...' : 'Remover'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
import VisitaForm from './VisitaForm';
interface Visita {
id: string;
user_id?: string;
property_id?: string;
message?: string;
status: string;
scheduled_at?: string;
created_at: string;
user_name?: string;
property_title?: string;
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Pendente',
confirmed: 'Confirmada',
cancelled: 'Cancelada',
completed: 'Concluída',
};
export default function AdminVisitasPage() {
const [visitas, setVisitas] = useState<Visita[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Visita | null>(null);
const [removing, setRemoving] = useState<Visita | null>(null);
const [actionLoading, setActionLoading] = useState(false);
function fetchVisitas() {
setLoading(true);
api.get('/admin/visitas')
.then(res => setVisitas(res.data))
.catch(() => setError('Erro ao carregar visitas'))
.finally(() => setLoading(false));
}
useEffect(() => { fetchVisitas(); }, []);
function handleCreate(data: { user_id: string; property_id?: string; message: string; status: string; scheduled_at?: string }) {
setActionLoading(true);
api.post('/admin/visitas', data)
.then(() => { setShowForm(false); fetchVisitas(); })
.catch(() => setError('Erro ao criar visita'))
.finally(() => setActionLoading(false));
}
function handleEdit(data: { user_id: string; property_id?: string; message: string; status: string; scheduled_at?: string }) {
if (!editing) return;
setActionLoading(true);
api.put(`/admin/visitas/${editing.id}`, { status: data.status, scheduled_at: data.scheduled_at || null })
.then(() => { setEditing(null); setShowForm(false); fetchVisitas(); })
.catch(() => setError('Erro ao editar visita'))
.finally(() => setActionLoading(false));
}
function handleRemove() {
if (!removing) return;
setActionLoading(true);
api.delete(`/admin/visitas/${removing.id}`)
.then(() => { setRemoving(null); fetchVisitas(); })
.catch(() => setError('Erro ao remover visita'))
.finally(() => setActionLoading(false));
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-brand/10 text-accent',
confirmed: 'bg-statusEmerald/10 text-statusEmerald',
cancelled: 'bg-red-500/10 text-red-400',
completed: 'bg-statusGreen/10 text-statusGreen',
};
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">
<h2 className="text-xl font-bold text-textPrimary">Visitas</h2>
<button
className="px-4 py-2 rounded bg-brand text-white font-medium hover:bg-accentHover transition-colors"
onClick={() => { setShowForm(true); setEditing(null); }}
>
+ Nova Visita
</button>
</div>
{loading && <div className="text-textSecondary text-sm">Carregando...</div>}
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="overflow-x-auto rounded-xl border border-borderPrimary">
<table className="min-w-full bg-panel text-sm">
<thead>
<tr className="text-left text-textSecondary border-b border-borderSubtle">
<th className="py-3 px-4 font-medium">Cliente</th>
<th className="py-3 px-4 font-medium hidden sm:table-cell">Imóvel</th>
<th className="py-3 px-4 font-medium hidden md:table-cell">Mensagem</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium hidden lg:table-cell">Agendado</th>
<th className="py-3 px-4 font-medium">Ações</th>
</tr>
</thead>
<tbody>
{visitas.length === 0 && !loading && (
<tr>
<td colSpan={6} className="py-8 text-center text-textTertiary">
Nenhuma visita encontrada.
</td>
</tr>
)}
{visitas.map(v => (
<tr key={v.id} className="border-t border-borderSubtle text-textPrimary hover:bg-surface/50 transition-colors">
<td className="py-3 px-4">{v.user_name || v.user_id || '—'}</td>
<td className="py-3 px-4 hidden sm:table-cell text-textSecondary">{v.property_title || '—'}</td>
<td className="py-3 px-4 hidden md:table-cell text-textSecondary max-w-xs truncate">{v.message || '—'}</td>
<td className="py-3 px-4">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[v.status] ?? 'bg-surface text-textTertiary'}`}>
{STATUS_LABELS[v.status] || v.status}
</span>
</td>
<td className="py-3 px-4 hidden lg:table-cell text-textSecondary">
{v.scheduled_at ? v.scheduled_at.replace('T', ' ').slice(0, 16) : '—'}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<button className="text-accent hover:text-accentHover text-xs font-medium transition-colors" onClick={() => { setEditing(v); setShowForm(true); }}>Editar</button>
<button className="text-red-400 hover:text-red-300 text-xs font-medium transition-colors" onClick={() => setRemoving(v)}>Remover</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal formulário */}
{(showForm || editing) && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-panel border border-borderPrimary rounded-xl shadow-lg p-6 w-full max-w-lg relative">
<VisitaForm
initial={editing ? editing : undefined}
onSubmit={editing ? handleEdit : handleCreate}
onCancel={() => { setShowForm(false); setEditing(null); }}
/>
{actionLoading && <div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-xl" />}
</div>
</div>
)}
{/* Modal remoção */}
{removing && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-panel border border-borderPrimary rounded-xl shadow-lg p-6 w-full max-w-md">
<h3 className="text-textPrimary font-semibold mb-2">Remover visita</h3>
<p className="text-textSecondary text-sm mb-4">
Tem certeza que deseja remover a visita de <span className="text-textPrimary font-medium">{removing.user_name || removing.user_id || '—'}</span>?
</p>
<div className="flex gap-2 justify-end">
<button
className="px-4 py-2 rounded border border-borderPrimary text-textSecondary hover:text-textPrimary hover:border-borderSecondary text-sm transition-colors"
onClick={() => setRemoving(null)}
>Cancelar</button>
<button
className="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white text-sm font-medium transition-colors disabled:opacity-50"
onClick={handleRemove}
disabled={actionLoading}
>Remover</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,95 @@
import axios from 'axios';
import { useEffect, useState } from 'react';
interface Cliente {
id: string;
name: string;
}
interface Imovel {
id: string;
title: string;
}
interface BoletoFormProps {
initial?: {
user_id: string;
property_id?: string;
description: string;
amount: number;
due_date: string;
url?: string;
};
onSubmit: (data: { user_id: string; property_id?: string; description: string; amount: number; due_date: string; url?: string }) => void;
onCancel: () => void;
}
export default function BoletoForm({ initial, onSubmit, onCancel }: BoletoFormProps) {
const [user_id, setUserId] = useState(initial?.user_id || '');
const [property_id, setPropertyId] = useState(initial?.property_id || '');
const [description, setDescription] = useState(initial?.description || '');
const [amount, setAmount] = useState(initial?.amount?.toString() || '');
const [due_date, setDueDate] = useState(initial?.due_date || '');
const [url, setUrl] = useState(initial?.url || '');
const [clientes, setClientes] = useState<Cliente[]>([]);
const [imoveis, setImoveis] = useState<Imovel[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
axios.get('/api/v1/admin/client-users').then(res => setClientes(res.data));
axios.get('/api/v1/properties').then(res => setImoveis(res.data));
}, []);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!user_id || !description || !amount || !due_date) {
setError('Preencha todos os campos obrigatórios.');
return;
}
if (isNaN(Number(amount))) {
setError('Valor inválido.');
return;
}
setError(null);
onSubmit({ user_id, property_id: property_id || undefined, description, amount: Number(amount), due_date, url: url || undefined });
}
return (
<form onSubmit={handleSubmit} className="space-y-4 p-4 bg-panel rounded shadow max-w-md mx-auto">
<div>
<label className="block text-white mb-1">Cliente *</label>
<select className="w-full rounded px-3 py-2 bg-surface text-white" value={user_id} onChange={e => setUserId(e.target.value)}>
<option value="">Selecione...</option>
{clientes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-white mb-1">Imóvel</label>
<select className="w-full rounded px-3 py-2 bg-surface text-white" value={property_id} onChange={e => setPropertyId(e.target.value)}>
<option value="">Nenhum</option>
{imoveis.map(i => <option key={i.id} value={i.id}>{i.title}</option>)}
</select>
</div>
<div>
<label className="block text-white mb-1">Descrição *</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div>
<label className="block text-white mb-1">Valor *</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
<div>
<label className="block text-white mb-1">Vencimento *</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" type="date" value={due_date} onChange={e => setDueDate(e.target.value)} />
</div>
<div>
<label className="block text-white mb-1">URL do boleto</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" value={url} onChange={e => setUrl(e.target.value)} />
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="flex gap-2 justify-end">
<button type="button" className="px-4 py-2 rounded bg-gray-600 text-white" onClick={onCancel}>Cancelar</button>
<button type="submit" className="px-4 py-2 rounded bg-brand text-white">Salvar</button>
</div>
</form>
);
}

View file

@ -0,0 +1,269 @@
import { useState } from 'react';
import type { ClientUser } from './AdminClientesPage';
type FormData = Omit<ClientUser, 'id' | 'created_at'> & { password?: string };
interface ClienteFormProps {
initial?: ClientUser;
onSubmit: (data: FormData) => void;
onCancel: () => void;
isEdit?: boolean;
loading?: boolean;
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-textSecondary text-sm mb-1">
{label}{required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{children}
</div>
);
}
const inputCls = 'w-full rounded px-3 py-2 bg-surface text-textPrimary border border-borderSubtle focus:outline-none focus:border-brand text-sm';
const selectCls = inputCls;
function maskCpf(v: string) {
return v.replace(/\D/g, '').slice(0, 11)
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
}
function maskPhone(v: string) {
const d = v.replace(/\D/g, '').slice(0, 11);
if (d.length <= 10)
return d.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3').trim().replace(/-$/, '');
return d.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3').trim().replace(/-$/, '');
}
function maskZip(v: string) {
return v.replace(/\D/g, '').slice(0, 8)
.replace(/(\d{5})(\d)/, '$1-$2');
}
export default function ClienteForm({ initial, onSubmit, onCancel, isEdit, loading }: ClienteFormProps) {
const [name, setName] = useState(initial?.name ?? '');
const [email, setEmail] = useState(initial?.email ?? '');
const [role, setRole] = useState(initial?.role ?? 'client');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [whatsapp, setWhatsapp] = useState(initial?.whatsapp ?? '');
const [cpf, setCpf] = useState(initial?.cpf ?? '');
const [birthDate, setBirthDate] = useState(initial?.birth_date ?? '');
const [street, setStreet] = useState(initial?.address_street ?? '');
const [number, setNumber] = useState(initial?.address_number ?? '');
const [complement, setComplement] = useState(initial?.address_complement ?? '');
const [addrNeighborhood, setAddrNeighborhood] = useState(initial?.address_neighborhood ?? '');
const [addrCity, setAddrCity] = useState(initial?.address_city ?? '');
const [addrState, setAddrState] = useState(initial?.address_state ?? '');
const [zip, setZip] = useState(initial?.address_zip ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim() || !email.trim()) {
setError('Nome e e-mail são obrigatórios.');
return;
}
if (!isEdit && !password) {
setError('A senha é obrigatória para novos clientes.');
return;
}
if (!isEdit && password.length < 8) {
setError('A senha deve ter pelo menos 8 caracteres.');
return;
}
setError(null);
onSubmit({
name: name.trim(),
email: email.trim(),
role,
password: password || undefined,
phone: phone || undefined,
whatsapp: whatsapp || undefined,
cpf: cpf || undefined,
birth_date: birthDate || undefined,
address_street: street || undefined,
address_number: number || undefined,
address_complement: complement || undefined,
address_neighborhood: addrNeighborhood || undefined,
address_city: addrCity || undefined,
address_state: addrState || undefined,
address_zip: zip || undefined,
notes: notes || undefined,
});
}
return (
<div className="min-h-screen bg-canvas p-6 md:p-10">
<div className="max-w-3xl mx-auto">
{/* Cabeçalho */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold text-textPrimary">
{isEdit ? 'Editar cliente' : 'Novo cliente'}
</h2>
<button
type="button"
className="text-textSecondary hover:text-textPrimary text-sm"
onClick={onCancel}
>
Voltar
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* ── Dados pessoais ── */}
<section className="bg-panel rounded-xl p-6 space-y-4">
<h3 className="text-textSecondary text-xs font-semibold uppercase tracking-widest mb-2">Dados pessoais</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Nome completo" required>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field label="E-mail" required>
<input className={inputCls} value={email} onChange={e => setEmail(e.target.value)} type="email" />
</Field>
<Field label="CPF">
<input
className={inputCls}
value={cpf}
onChange={e => setCpf(maskCpf(e.target.value))}
placeholder="000.000.000-00"
maxLength={14}
/>
</Field>
<Field label="Data de nascimento">
<input className={inputCls} value={birthDate} onChange={e => setBirthDate(e.target.value)} type="date" />
</Field>
</div>
</section>
{/* ── Contato ── */}
<section className="bg-panel rounded-xl p-6 space-y-4">
<h3 className="text-textSecondary text-xs font-semibold uppercase tracking-widest mb-2">Contato</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Telefone">
<input
className={inputCls}
value={phone}
onChange={e => setPhone(maskPhone(e.target.value))}
placeholder="(00) 0000-0000"
maxLength={15}
/>
</Field>
<Field label="WhatsApp">
<input
className={inputCls}
value={whatsapp}
onChange={e => setWhatsapp(maskPhone(e.target.value))}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</Field>
</div>
</section>
{/* ── Endereço ── */}
<section className="bg-panel rounded-xl p-6 space-y-4">
<h3 className="text-textSecondary text-xs font-semibold uppercase tracking-widest mb-2">Endereço</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2">
<Field label="Logradouro">
<input className={inputCls} value={street} onChange={e => setStreet(e.target.value)} placeholder="Rua, Av., Alameda..." />
</Field>
</div>
<Field label="Número">
<input className={inputCls} value={number} onChange={e => setNumber(e.target.value)} />
</Field>
<Field label="Complemento">
<input className={inputCls} value={complement} onChange={e => setComplement(e.target.value)} placeholder="Apto, bloco..." />
</Field>
<Field label="Bairro">
<input className={inputCls} value={addrNeighborhood} onChange={e => setAddrNeighborhood(e.target.value)} />
</Field>
<Field label="CEP">
<input
className={inputCls}
value={zip}
onChange={e => setZip(maskZip(e.target.value))}
placeholder="00000-000"
maxLength={9}
/>
</Field>
<div className="sm:col-span-2">
<Field label="Cidade">
<input className={inputCls} value={addrCity} onChange={e => setAddrCity(e.target.value)} />
</Field>
</div>
<Field label="Estado (UF)">
<input className={inputCls} value={addrState} onChange={e => setAddrState(e.target.value.toUpperCase().slice(0, 2))} placeholder="SP" maxLength={2} />
</Field>
</div>
</section>
{/* ── Acesso ── */}
<section className="bg-panel rounded-xl p-6 space-y-4">
<h3 className="text-textSecondary text-xs font-semibold uppercase tracking-widest mb-2">Acesso</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{!isEdit && (
<Field label="Senha" required>
<input className={inputCls} value={password} onChange={e => setPassword(e.target.value)} type="password" autoComplete="new-password" />
</Field>
)}
<Field label="Tipo de conta">
<select className={selectCls} value={role} onChange={e => setRole(e.target.value)}>
<option value="client">Cliente</option>
<option value="admin">Admin</option>
</select>
</Field>
</div>
</section>
{/* ── Observações ── */}
<section className="bg-panel rounded-xl p-6 space-y-4">
<h3 className="text-textSecondary text-xs font-semibold uppercase tracking-widest mb-2">Observações internas</h3>
<textarea
className={`${inputCls} resize-y min-h-[80px]`}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Notas visíveis apenas para administradores..."
/>
</section>
{error && <div className="text-red-400 text-sm">{error}</div>}
{/* Ações */}
<div className="flex gap-3 justify-end pb-8">
<button
type="button"
className="px-5 py-2 rounded bg-surface text-textPrimary border border-borderSubtle hover:bg-panel transition-colors"
onClick={onCancel}
disabled={loading}
>
Cancelar
</button>
<button
type="submit"
className="px-5 py-2 rounded bg-brand text-white font-medium hover:bg-accentHover transition-colors disabled:opacity-50"
disabled={loading}
>
{loading ? 'Salvando...' : isEdit ? 'Salvar alterações' : 'Criar cliente'}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,745 @@
import api from '../../services/api';
import { useEffect, useRef, useState, useCallback } from 'react';
// ─── Tipos exportados ────────────────────────────────────────────────────────
export interface PhotoItem {
url: string;
alt_text: string;
display_order: number;
}
export interface PropertyFormData {
title: string;
code: string;
type: 'venda' | 'aluguel';
is_active: boolean;
is_featured: boolean;
address: string;
city_id: number | '';
neighborhood_id: number | '';
price: string;
condo_fee: string;
iptu_anual: string;
bedrooms: number;
bathrooms: number;
parking_spots: number;
parking_spots_covered: number;
area_m2: number;
description: string;
photos: PhotoItem[];
amenity_ids: number[];
}
// ─── Tipos internos ──────────────────────────────────────────────────────────
interface CityOption { id: number; name: string; state: string; }
interface NeighborhoodOption { id: number; name: string; city_id: number; }
interface AmenityOption { id: number; name: string; group: string; }
interface PropertyFormProps {
initial?: Partial<PropertyFormData & { id: string }>;
onSubmit: (data: PropertyFormData) => void;
onCancel: () => void;
isLoading?: boolean;
}
type FieldErrors = Partial<Record<keyof PropertyFormData | '_global', string>>;
const AMENITY_GROUPS: { key: string; label: string }[] = [
{ key: 'caracteristica', label: 'Características' },
{ key: 'lazer', label: 'Lazer' },
{ key: 'condominio', label: 'Condomínio' },
{ key: 'seguranca', label: 'Segurança' },
];
const BR_STATES = [
'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG',
'PA', 'PB', 'PR', 'PE', 'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO',
];
// ─── Helpers de UI ───────────────────────────────────────────────────────────
function Label({ children, required }: { children: React.ReactNode; required?: boolean }) {
return (
<label className="block text-textSecondary text-[11px] font-semibold mb-1.5 tracking-widest uppercase">
{children}{required && <span className="text-red-400 ml-0.5">*</span>}
</label>
);
}
function SectionDivider({ title }: { title: string }) {
return (
<div className="flex items-center gap-3 pt-3 pb-1">
<span className="text-textTertiary text-[11px] font-semibold uppercase tracking-widest whitespace-nowrap">{title}</span>
<div className="flex-1 h-px bg-borderSubtle" />
</div>
);
}
function inputCls(err?: string) {
return `w-full px-3 py-2 rounded-lg border bg-canvas text-textPrimary placeholder:text-textTertiary text-sm focus:outline-none focus:ring-2 transition ${err ? 'border-red-500 focus:ring-red-500/30' : 'border-borderPrimary focus:ring-brand/30 focus:border-brand/60'
}`;
}
function ErrMsg({ msg }: { msg?: string }) {
if (!msg) return null;
return <p className="text-xs text-red-400 mt-1">{msg}</p>;
}
function NumStepper({ label, value, onChange, required, err }: {
label: string; value: number; onChange: (v: number) => void; required?: boolean; err?: string;
}) {
return (
<div>
<Label required={required}>{label}</Label>
<div className={`flex items-center rounded-lg border overflow-hidden ${err ? 'border-red-500' : 'border-borderPrimary'}`}>
<button type="button" onClick={() => onChange(Math.max(0, value - 1))}
className="w-9 h-9 flex items-center justify-center text-textSecondary hover:text-textPrimary hover:bg-surface transition text-lg border-r border-borderPrimary"></button>
<div className="flex-1 h-9 bg-canvas text-textPrimary text-sm flex items-center justify-center font-medium">
{value}
</div>
<button type="button" onClick={() => onChange(value + 1)}
className="w-9 h-9 flex items-center justify-center text-textSecondary hover:text-textPrimary hover:bg-surface transition text-lg border-l border-borderPrimary">+</button>
</div>
<ErrMsg msg={err} />
</div>
);
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
onClick={() => onChange(!checked)}
className={`relative w-9 h-5 rounded-full transition-colors ${checked ? 'bg-brand' : 'bg-borderPrimary'}`}
>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${checked ? 'translate-x-4' : ''}`} />
</div>
<span className="text-textSecondary text-sm">{label}</span>
</label>
);
}
// ─── Mini-modal de adição rápida ──────────────────────────────────────────────
function QuickModal({ title, onClose, children }: {
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative bg-panel border border-borderPrimary rounded-xl shadow-2xl w-full max-w-sm p-5 z-10">
<div className="flex items-center justify-between mb-4">
<h3 className="text-textPrimary font-semibold text-sm">{title}</h3>
<button type="button" onClick={onClose}
className="w-7 h-7 flex items-center justify-center rounded text-textTertiary hover:text-textPrimary hover:bg-surface transition text-lg">×</button>
</div>
{children}
</div>
</div>
);
}
// ─── Upload de fotos ──────────────────────────────────────────────────────────
function PhotoUploader({ photos, onChange, err }: {
photos: PhotoItem[];
onChange: (p: PhotoItem[]) => void;
err?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadErr, setUploadErr] = useState('');
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploadErr('');
setUploading(true);
const newPhotos: PhotoItem[] = [...photos];
for (const file of Array.from(files)) {
const formData = new FormData();
formData.append('file', file);
try {
const res = await api.post('/admin/upload/photo', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
newPhotos.push({
url: res.data.url,
alt_text: '',
display_order: newPhotos.length,
});
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Falha no upload';
setUploadErr(msg);
}
}
onChange(newPhotos);
setUploading(false);
};
const removePhoto = (idx: number) =>
onChange(photos.filter((_, i) => i !== idx).map((p, i) => ({ ...p, display_order: i })));
const moveUp = (idx: number) => {
if (idx === 0) return;
const next = [...photos];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
onChange(next.map((p, i) => ({ ...p, display_order: i })));
};
const moveDown = (idx: number) => {
if (idx === photos.length - 1) return;
const next = [...photos];
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
onChange(next.map((p, i) => ({ ...p, display_order: i })));
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
};
return (
<div className="space-y-3">
<div
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
onClick={() => inputRef.current?.click()}
className={`flex flex-col items-center justify-center gap-2 p-6 rounded-xl border-2 border-dashed cursor-pointer transition
${err ? 'border-red-500' : 'border-borderPrimary hover:border-brand/60'}`}
>
<svg className="w-8 h-8 text-textTertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-textSecondary text-center">
{uploading ? 'Enviando…' : 'Clique ou arraste fotos aqui'}
</p>
<p className="text-xs text-textTertiary">JPG, PNG, WEBP, GIF máx. 5 MB por arquivo</p>
</div>
<input ref={inputRef} type="file" accept="image/*" multiple className="hidden"
onChange={e => handleFiles(e.target.files)} />
{(uploadErr || err) && <p className="text-xs text-red-400">{uploadErr || err}</p>}
{photos.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{photos.map((ph, idx) => (
<div key={idx} className="relative group rounded-lg overflow-hidden border border-borderPrimary aspect-video bg-canvas">
<img
src={ph.url}
alt={ph.alt_text || `Foto ${idx + 1}`}
className="w-full h-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
{idx === 0 && (
<span className="absolute top-1.5 left-1.5 bg-brand text-black text-[10px] font-bold px-1.5 py-0.5 rounded">
CAPA
</span>
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-1.5 transition">
<button type="button" onClick={() => moveUp(idx)} disabled={idx === 0}
className="w-7 h-7 rounded bg-white/10 text-white hover:bg-white/25 disabled:opacity-30 transition flex items-center justify-center text-xs"></button>
<button type="button" onClick={() => moveDown(idx)} disabled={idx === photos.length - 1}
className="w-7 h-7 rounded bg-white/10 text-white hover:bg-white/25 disabled:opacity-30 transition flex items-center justify-center text-xs"></button>
<button type="button" onClick={() => removePhoto(idx)}
className="w-7 h-7 rounded bg-red-500/70 text-white hover:bg-red-500 transition flex items-center justify-center text-xs">×</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ─── Botão adicionar rápido ───────────────────────────────────────────────────
function AddBtn({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
title={title}
className="flex items-center gap-1 text-[11px] text-accent hover:text-brand transition font-medium"
>
<span className="text-base leading-none">+</span> novo
</button>
);
}
// ─── Formulário principal ─────────────────────────────────────────────────────
export default function PropertyForm({ initial, onSubmit, onCancel, isLoading }: PropertyFormProps) {
const [title, setTitle] = useState(initial?.title ?? '');
const [code, setCode] = useState(initial?.code ?? '');
const [type, setType] = useState<'venda' | 'aluguel'>(initial?.type ?? 'venda');
const [isActive, setIsActive] = useState(initial?.is_active ?? true);
const [isFeatured, setIsFeatured] = useState(initial?.is_featured ?? false);
const [address, setAddress] = useState(initial?.address ?? '');
const [cityId, setCityId] = useState<number | ''>(initial?.city_id ?? '');
const [neighborhoodId, setNeighborhoodId] = useState<number | ''>(initial?.neighborhood_id ?? '');
const [price, setPrice] = useState(initial?.price ?? '');
const [condoFee, setCondoFee] = useState(initial?.condo_fee ?? '');
const [iptuAnual, setIptuAnual] = useState(initial?.iptu_anual ?? '');
const [bedrooms, setBedrooms] = useState(initial?.bedrooms ?? 0);
const [bathrooms, setBathrooms] = useState(initial?.bathrooms ?? 0);
const [parkingSpots, setParkingSpots] = useState(initial?.parking_spots ?? 0);
const [parkingCovered, setParkingCovered] = useState(initial?.parking_spots_covered ?? 0);
const [areaM2, setAreaM2] = useState(initial?.area_m2 ?? 0);
const [description, setDescription] = useState(initial?.description ?? '');
const [photos, setPhotos] = useState<PhotoItem[]>(initial?.photos ?? []);
const [amenityIds, setAmenityIds] = useState<number[]>(initial?.amenity_ids ?? []);
const [cities, setCities] = useState<CityOption[]>([]);
const [neighborhoods, setNeighborhoods] = useState<NeighborhoodOption[]>([]);
const [allAmenities, setAllAmenities] = useState<AmenityOption[]>([]);
const [errors, setErrors] = useState<FieldErrors>({});
const [isDirty, setIsDirty] = useState(false);
// ─── Quick-add modals ────────────────────────────────────────────────────
const [showAddCity, setShowAddCity] = useState(false);
const [newCityName, setNewCityName] = useState('');
const [newCityState, setNewCityState] = useState('SP');
const [savingCity, setSavingCity] = useState(false);
const [showAddNb, setShowAddNb] = useState(false);
const [newNbName, setNewNbName] = useState('');
const [savingNb, setSavingNb] = useState(false);
const [showAddAmenity, setShowAddAmenity] = useState(false);
const [newAmenityName, setNewAmenityName] = useState('');
const [newAmenityGroup, setNewAmenityGroup] = useState('caracteristica');
const [savingAmenity, setSavingAmenity] = useState(false);
// ─── beforeunload guard ───────────────────────────────────────────────────
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) { e.preventDefault(); e.returnValue = ''; }
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isDirty]);
const markDirty = useCallback(() => setIsDirty(true), []);
// ─── Carga de dados ───────────────────────────────────────────────────────
const loadCities = useCallback(() =>
api.get('/admin/cities').then(r => setCities(r.data)).catch(() => null), []);
const loadAmenities = useCallback(() =>
api.get('/admin/amenities').then(r => setAllAmenities(r.data)).catch(() => null), []);
const loadNeighborhoods = useCallback((cid: number) =>
api.get('/admin/neighborhoods', { params: { city_id: cid } })
.then(r => setNeighborhoods(r.data))
.catch(() => setNeighborhoods([])), []);
useEffect(() => {
loadCities();
loadAmenities();
if (!initial?.id && !initial?.code) {
api.get('/admin/next-property-code').then(r => setCode(r.data.code)).catch(() => null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setNeighborhoodId('');
if (!cityId) { setNeighborhoods([]); return; }
loadNeighborhoods(cityId as number);
}, [cityId, loadNeighborhoods]);
useEffect(() => {
if (initial?.city_id && cityId === initial.city_id && initial.neighborhood_id) {
setNeighborhoodId(initial.neighborhood_id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [neighborhoods]);
// ─── Quick-add handlers ───────────────────────────────────────────────────
async function handleQuickAddCity() {
if (!newCityName.trim()) return;
setSavingCity(true);
try {
const res = await api.post('/admin/cities', { name: newCityName.trim(), state: newCityState });
await loadCities();
setCityId(res.data.id);
markDirty();
setShowAddCity(false);
setNewCityName('');
} finally {
setSavingCity(false);
}
}
async function handleQuickAddNb() {
if (!newNbName.trim() || !cityId) return;
setSavingNb(true);
try {
const res = await api.post('/admin/neighborhoods', { name: newNbName.trim(), city_id: cityId });
await loadNeighborhoods(cityId as number);
setNeighborhoodId(res.data.id);
markDirty();
setShowAddNb(false);
setNewNbName('');
} finally {
setSavingNb(false);
}
}
async function handleQuickAddAmenity() {
if (!newAmenityName.trim()) return;
setSavingAmenity(true);
try {
const res = await api.post('/admin/amenities', { name: newAmenityName.trim(), group: newAmenityGroup });
await loadAmenities();
setAmenityIds(prev => [...prev, res.data.id]);
markDirty();
setShowAddAmenity(false);
setNewAmenityName('');
} finally {
setSavingAmenity(false);
}
}
// ─── Validação ────────────────────────────────────────────────────────────
function validate(): boolean {
const e: FieldErrors = {};
if (!title.trim()) e.title = 'Nome do imóvel obrigatório';
if (!price || isNaN(Number(price)) || Number(price) <= 0) e.price = 'Preço inválido ou obrigatório';
if (condoFee && isNaN(Number(condoFee))) e.condo_fee = 'Valor inválido';
if (iptuAnual && isNaN(Number(iptuAnual))) e.iptu_anual = 'Valor inválido';
if (areaM2 <= 0) e.area_m2 = 'Área deve ser maior que zero';
setErrors(e);
return Object.keys(e).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) {
const first = document.querySelector('[data-err]') as HTMLElement | null;
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
setIsDirty(false);
onSubmit({
title: title.trim(), code: code.trim(), type,
is_active: isActive, is_featured: isFeatured,
address: address.trim(), city_id: cityId, neighborhood_id: neighborhoodId,
price, condo_fee: condoFee, iptu_anual: iptuAnual,
bedrooms, bathrooms, parking_spots: parkingSpots,
parking_spots_covered: parkingCovered, area_m2: areaM2,
description: description.trim(), photos, amenity_ids: amenityIds,
});
}
function handleCancel() {
if (isDirty && !confirm('Há alterações não salvas. Deseja cancelar?')) return;
setIsDirty(false);
onCancel();
}
const toggleAmenity = (id: number) => {
markDirty();
setAmenityIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-canvas" style={{ top: '56px' }}>
<form onSubmit={handleSubmit} className="flex flex-col h-full">
{/* ── Cabeçalho fixo ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-borderPrimary bg-panel shrink-0">
<div>
<h2 className="text-textPrimary font-bold text-lg">
{initial?.id ? 'Editar Imóvel' : 'Novo Imóvel'}
</h2>
{isDirty && <p className="text-xs text-amber-400 mt-0.5">Alterações não salvas</p>}
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={handleCancel}
className="px-4 py-2 rounded-lg border border-borderPrimary text-textSecondary text-sm hover:bg-surface transition">
Cancelar
</button>
<button type="submit" disabled={isLoading}
className="px-5 py-2 rounded-lg bg-brand text-black text-sm font-semibold hover:bg-accentHover disabled:opacity-50 transition">
{isLoading ? 'Salvando…' : initial?.id ? 'Salvar Alterações' : 'Criar Imóvel'}
</button>
</div>
</div>
{/* ── Corpo rolável ── */}
<div className="flex-1 overflow-y-auto" onChange={markDirty}>
<div className="max-w-4xl mx-auto px-6 py-6 space-y-5">
{/* ── Identificação ── */}
<SectionDivider title="Identificação" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2" data-err={errors.title || undefined}>
<Label required>Nome do imóvel</Label>
<input value={title}
onChange={e => { setTitle(e.target.value); markDirty(); }}
placeholder="Ex: Apartamento 3 dormitórios Alto Padrão"
className={inputCls(errors.title)} />
<ErrMsg msg={errors.title} />
</div>
<div>
<Label>Código</Label>
<input value={code}
onChange={e => { setCode(e.target.value); markDirty(); }}
placeholder="IM-0001"
className={inputCls()} />
</div>
<div>
<Label required>Tipo de negócio</Label>
<select value={type}
onChange={e => { setType(e.target.value as 'venda' | 'aluguel'); markDirty(); }}
className={inputCls()}>
<option value="venda">Venda</option>
<option value="aluguel">Aluguel</option>
</select>
</div>
</div>
<div className="flex gap-6 flex-wrap">
<Toggle label="Imóvel ativo" checked={isActive} onChange={v => { setIsActive(v); markDirty(); }} />
<Toggle label="Em destaque" checked={isFeatured} onChange={v => { setIsFeatured(v); markDirty(); }} />
</div>
{/* ── Localização ── */}
<SectionDivider title="Localização" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<Label>Endereço completo</Label>
<input value={address}
onChange={e => { setAddress(e.target.value); markDirty(); }}
placeholder="Rua, número, complemento"
className={inputCls()} />
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<Label>Cidade</Label>
<AddBtn onClick={() => setShowAddCity(true)} title="Adicionar nova cidade" />
</div>
<select value={cityId}
onChange={e => { setCityId(e.target.value ? Number(e.target.value) : ''); markDirty(); }}
className={inputCls()}>
<option value="">Selecione a cidade</option>
{cities.map(c => <option key={c.id} value={c.id}>{c.name} {c.state}</option>)}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<Label>Bairro</Label>
{cityId && <AddBtn onClick={() => setShowAddNb(true)} title="Adicionar novo bairro" />}
</div>
<select value={neighborhoodId}
onChange={e => { setNeighborhoodId(e.target.value ? Number(e.target.value) : ''); markDirty(); }}
disabled={!cityId}
className={`${inputCls()} disabled:opacity-40`}>
<option value="">Selecione o bairro</option>
{neighborhoods.map(n => <option key={n.id} value={n.id}>{n.name}</option>)}
</select>
</div>
</div>
{/* ── Valores ── */}
<SectionDivider title="Valores" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div data-err={errors.price || undefined}>
<Label required>Preço (R$)</Label>
<input type="number" min="0" step="0.01" value={price}
onChange={e => { setPrice(e.target.value); markDirty(); }}
placeholder="0,00" className={inputCls(errors.price)} />
<ErrMsg msg={errors.price} />
</div>
<div data-err={errors.condo_fee || undefined}>
<Label>Condomínio (R$)</Label>
<input type="number" min="0" step="0.01" value={condoFee}
onChange={e => { setCondoFee(e.target.value); markDirty(); }}
placeholder="0,00" className={inputCls(errors.condo_fee)} />
<ErrMsg msg={errors.condo_fee} />
</div>
<div data-err={errors.iptu_anual || undefined}>
<Label>IPTU Anual (R$)</Label>
<input type="number" min="0" step="0.01" value={iptuAnual}
onChange={e => { setIptuAnual(e.target.value); markDirty(); }}
placeholder="0,00" className={inputCls(errors.iptu_anual)} />
<ErrMsg msg={errors.iptu_anual} />
</div>
</div>
{/* ── Características ── */}
<SectionDivider title="Características" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<NumStepper label="Quartos" value={bedrooms} onChange={v => { setBedrooms(v); markDirty(); }} />
<NumStepper label="Banheiros" value={bathrooms} onChange={v => { setBathrooms(v); markDirty(); }} />
<NumStepper label="Vagas totais" value={parkingSpots} onChange={v => { setParkingSpots(v); markDirty(); }} />
<NumStepper label="Vagas cobertas" value={parkingCovered} onChange={v => { setParkingCovered(v); markDirty(); }} />
<div data-err={errors.area_m2 || undefined}>
<Label required>Área (m²)</Label>
<input type="number" min="0" step="0.01" value={areaM2 || ''}
onChange={e => { setAreaM2(Number(e.target.value)); markDirty(); }}
placeholder="0" className={inputCls(errors.area_m2)} />
<ErrMsg msg={errors.area_m2} />
</div>
</div>
{/* ── Amenidades ── */}
<div className="flex items-center gap-3 pt-3 pb-1">
<span className="text-textTertiary text-[11px] font-semibold uppercase tracking-widest whitespace-nowrap">Amenidades</span>
<div className="flex-1 h-px bg-borderSubtle" />
<AddBtn onClick={() => setShowAddAmenity(true)} title="Adicionar nova amenidade" />
</div>
{allAmenities.length === 0 ? (
<p className="text-xs text-textTertiary">Nenhuma amenidade cadastrada. Clique em "+ novo" para adicionar.</p>
) : (
<div className="space-y-4">
{AMENITY_GROUPS.map(g => {
const items = allAmenities.filter(a => a.group === g.key);
if (items.length === 0) return null;
return (
<div key={g.key}>
<p className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-2">{g.label}</p>
<div className="flex flex-wrap gap-2">
{items.map(a => {
const selected = amenityIds.includes(a.id);
return (
<button key={a.id} type="button" onClick={() => toggleAmenity(a.id)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${selected
? 'bg-brand text-black border-brand'
: 'bg-surface text-textSecondary border-borderSubtle hover:border-brand/50'
}`}>
{a.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* ── Descrição ── */}
<SectionDivider title="Descrição" />
<textarea value={description} rows={5}
onChange={e => { setDescription(e.target.value); markDirty(); }}
placeholder="Descreva os detalhes, diferenciais e características do imóvel…"
className={`${inputCls()} resize-none`} />
{/* ── Fotos ── */}
<SectionDivider title="Fotos" />
<PhotoUploader
photos={photos}
onChange={p => { setPhotos(p); markDirty(); }}
/>
<div className="h-4" />
</div>
</div>
</form>
{/* ── Modal: Nova Cidade ── */}
{showAddCity && (
<QuickModal title="Nova Cidade" onClose={() => setShowAddCity(false)}>
<div className="space-y-3">
<div>
<Label required>Nome da cidade</Label>
<input
autoFocus
value={newCityName}
onChange={e => setNewCityName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleQuickAddCity()}
placeholder="Ex: São Paulo"
className={inputCls()}
/>
</div>
<div>
<Label required>Estado</Label>
<select value={newCityState} onChange={e => setNewCityState(e.target.value)} className={inputCls()}>
{BR_STATES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => setShowAddCity(false)}
className="flex-1 py-2 rounded-lg border border-borderPrimary text-textSecondary text-sm hover:bg-surface transition">
Cancelar
</button>
<button type="button" onClick={handleQuickAddCity} disabled={savingCity || !newCityName.trim()}
className="flex-1 py-2 rounded-lg bg-brand text-black text-sm font-semibold hover:bg-accentHover disabled:opacity-50 transition">
{savingCity ? 'Salvando…' : 'Adicionar'}
</button>
</div>
</div>
</QuickModal>
)}
{/* ── Modal: Novo Bairro ── */}
{showAddNb && (
<QuickModal title="Novo Bairro" onClose={() => setShowAddNb(false)}>
<div className="space-y-3">
<div>
<Label required>Nome do bairro</Label>
<input
autoFocus
value={newNbName}
onChange={e => setNewNbName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleQuickAddNb()}
placeholder="Ex: Centro"
className={inputCls()}
/>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => setShowAddNb(false)}
className="flex-1 py-2 rounded-lg border border-borderPrimary text-textSecondary text-sm hover:bg-surface transition">
Cancelar
</button>
<button type="button" onClick={handleQuickAddNb} disabled={savingNb || !newNbName.trim()}
className="flex-1 py-2 rounded-lg bg-brand text-black text-sm font-semibold hover:bg-accentHover disabled:opacity-50 transition">
{savingNb ? 'Salvando…' : 'Adicionar'}
</button>
</div>
</div>
</QuickModal>
)}
{/* ── Modal: Nova Amenidade ── */}
{showAddAmenity && (
<QuickModal title="Nova Amenidade" onClose={() => setShowAddAmenity(false)}>
<div className="space-y-3">
<div>
<Label required>Nome</Label>
<input
autoFocus
value={newAmenityName}
onChange={e => setNewAmenityName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleQuickAddAmenity()}
placeholder="Ex: Churrasqueira"
className={inputCls()}
/>
</div>
<div>
<Label required>Grupo</Label>
<select value={newAmenityGroup} onChange={e => setNewAmenityGroup(e.target.value)} className={inputCls()}>
{AMENITY_GROUPS.map(g => <option key={g.key} value={g.key}>{g.label}</option>)}
</select>
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => setShowAddAmenity(false)}
className="flex-1 py-2 rounded-lg border border-borderPrimary text-textSecondary text-sm hover:bg-surface transition">
Cancelar
</button>
<button type="button" onClick={handleQuickAddAmenity} disabled={savingAmenity || !newAmenityName.trim()}
className="flex-1 py-2 rounded-lg bg-brand text-black text-sm font-semibold hover:bg-accentHover disabled:opacity-50 transition">
{savingAmenity ? 'Salvando…' : 'Adicionar'}
</button>
</div>
</div>
</QuickModal>
)}
</div>
);
}

View file

@ -0,0 +1,73 @@
import { useState } from 'react';
interface VisitaFormProps {
initial?: {
user_id?: string;
property_id?: string;
message?: string;
status?: string;
scheduled_at?: string;
};
onSubmit: (data: { user_id: string; property_id?: string; message: string; status: string; scheduled_at?: string }) => void;
onCancel: () => void;
}
export default function VisitaForm({ initial, onSubmit, onCancel }: VisitaFormProps) {
const [user_id, setUserId] = useState(initial?.user_id || '');
const [property_id, setPropertyId] = useState(initial?.property_id || '');
const [message, setMessage] = useState(initial?.message || '');
const [status, setStatus] = useState(initial?.status || 'pending');
const [scheduled_at, setScheduledAt] = useState(initial?.scheduled_at?.slice(0, 16) || '');
const [error, setError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!message) {
setError('Preencha a mensagem.');
return;
}
setError(null);
onSubmit({
user_id,
property_id: property_id || undefined,
message,
status,
scheduled_at: scheduled_at || undefined,
});
}
return (
<form onSubmit={handleSubmit} className="space-y-4 p-4 bg-panel rounded shadow max-w-md mx-auto">
<div>
<label className="block text-white mb-1">ID do Cliente</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" value={user_id} onChange={e => setUserId(e.target.value)} />
</div>
<div>
<label className="block text-white mb-1">ID do Imóvel</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" value={property_id} onChange={e => setPropertyId(e.target.value)} />
</div>
<div>
<label className="block text-white mb-1">Mensagem *</label>
<textarea className="w-full rounded px-3 py-2 bg-surface text-white" value={message} onChange={e => setMessage(e.target.value)} rows={3} />
</div>
<div>
<label className="block text-white mb-1">Status *</label>
<select className="w-full rounded px-3 py-2 bg-surface text-white" value={status} onChange={e => setStatus(e.target.value)}>
<option value="pending">Pendente</option>
<option value="confirmed">Confirmada</option>
<option value="cancelled">Cancelada</option>
<option value="completed">Concluída</option>
</select>
</div>
<div>
<label className="block text-white mb-1">Data/Hora Agendada</label>
<input className="w-full rounded px-3 py-2 bg-surface text-white" type="datetime-local" value={scheduled_at} onChange={e => setScheduledAt(e.target.value)} />
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="flex gap-2 justify-end">
<button type="button" className="px-4 py-2 rounded bg-gray-600 text-white" onClick={onCancel}>Cancelar</button>
<button type="submit" className="px-4 py-2 rounded bg-brand text-white">Salvar</button>
</div>
</form>
);
}

View file

@ -0,0 +1,100 @@
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,97 @@
import { Link } from 'react-router-dom';
import { useComparison } from '../../contexts/ComparisonContext';
const COMPARISON_FIELDS = [
{ label: 'Preço', key: 'price', format: (v: number | string) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(typeof v === 'string' ? parseFloat(v) : v) },
{ label: 'Área', key: 'area_m2', format: (v: number | null) => v ? `${v}` : '—' },
{ label: 'Quartos', key: 'bedrooms', format: (v: number | null) => v ?? '—' },
{ label: 'Banheiros', key: 'bathrooms', format: (v: number | null) => v ?? '—' },
{ label: 'Vagas', key: 'parking_spots', format: (v: number | null) => v ?? '—' },
{ label: 'Condomínio', key: 'condo_fee', format: (v: number | string | null) => v ? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(typeof v === 'string' ? parseFloat(v) : v) : '—' },
{ label: 'Tipo', key: 'subtype', format: (v: { name: string } | null) => v?.name ?? '—' },
{ label: 'Bairro', key: 'neighborhood', format: (v: { name: string } | null) => v?.name ?? '—' },
{ label: 'Cidade', key: 'city', format: (v: { name: string } | null) => v?.name ?? '—' },
];
export default function ComparisonPage() {
const { properties, remove, clear } = useComparison();
if (properties.length === 0) {
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">
Explorar imóveis
</Link>
</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-textPrimary">Comparar Imóveis</h1>
<button
onClick={clear}
className="text-sm text-textTertiary hover:text-textPrimary transition"
>
Limpar comparação
</button>
</div>
<div className="overflow-x-auto rounded-xl border border-borderSubtle bg-panel">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-borderSubtle">
<th className="w-32 px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Campo</th>
{properties.map(p => (
<th key={p.id} className="px-4 py-3 text-left">
<div className="flex items-start justify-between gap-2">
<Link to={`/imoveis/${p.slug}`} className="text-sm font-medium text-textPrimary hover:text-accent transition line-clamp-2">
{p.title}
</Link>
<button
onClick={() => remove(p.id)}
aria-label="Remover"
className="shrink-0 text-textQuaternary hover:text-textPrimary transition mt-0.5"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</th>
))}
{/* Empty columns for up to 3 */}
{Array.from({ length: 3 - properties.length }).map((_, i) => (
<th key={`empty-${i}`} className="px-4 py-3 text-left">
<Link to="/imoveis" className="text-sm text-textQuaternary hover:text-textSecondary transition">
+ Adicionar
</Link>
</th>
))}
</tr>
</thead>
<tbody>
{COMPARISON_FIELDS.map(field => (
<tr key={field.key} className="border-b border-borderSubtle hover:bg-surface transition">
<td className="px-4 py-3 text-xs text-textTertiary font-medium uppercase tracking-wide">{field.label}</td>
{properties.map(p => (
<td key={p.id} className="px-4 py-3 text-textPrimary">
{field.format((p as any)[field.key])}
</td>
))}
{Array.from({ length: 3 - properties.length }).map((_, i) => (
<td key={`empty-${i}`} className="px-4 py-3 text-textQuaternary"></td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import HeartButton from '../../components/HeartButton';
import { getFavorites } from '../../services/clientArea';
export default function FavoritesPage() {
const [favorites, setFavorites] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getFavorites()
.then(data => setFavorites(Array.isArray(data) ? data : []))
.catch(() => setFavorites([]))
.finally(() => setLoading(false));
}, []);
if (loading) {
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>
</div>
);
}
return (
<div className="p-6 max-w-5xl">
<h1 className="text-xl font-semibold text-textPrimary mb-6">Meus Favoritos</h1>
{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>
) : (
<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>
)}
</div>
);
}

View file

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getVisits } from '../../services/clientArea';
import type { VisitRequest } 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' },
confirmed: { label: 'Confirmada', color: 'bg-green-500/10 text-green-400 border-green-500/20' },
cancelled: { label: 'Cancelada', color: 'bg-red-500/10 text-red-400 border-red-500/20' },
completed: { label: 'Realizada', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
};
export default function VisitsPage() {
const [visits, setVisits] = useState<VisitRequest[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getVisits()
.then(setVisits)
.catch(() => setVisits([]))
.finally(() => setLoading(false));
}, []);
const formatDate = (d: string | null) => {
if (!d) return '—';
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(d));
};
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-24 animate-pulse rounded-xl bg-panel border border-borderSubtle" />
))}
</div>
);
}
return (
<div className="p-6 max-w-3xl">
<h1 className="text-xl font-semibold text-textPrimary mb-6">Minhas Visitas</h1>
{visits.length === 0 ? (
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
<p className="text-textTertiary mb-4">Nenhuma visita agendada</p>
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
Ver imóveis disponíveis
</Link>
</div>
) : (
<div className="space-y-3">
{visits.map(visit => {
const status = STATUS_LABELS[visit.status] ?? { label: visit.status, color: 'bg-white/10 text-white/60' };
return (
<div key={visit.id} className="rounded-xl border border-borderSubtle bg-panel p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
{visit.property ? (
<Link
to={`/imoveis/${visit.property.slug}`}
className="text-sm font-medium text-textPrimary hover:text-accent transition truncate block"
>
{visit.property.title}
</Link>
) : (
<p className="text-sm font-medium text-textTertiary">Imóvel não disponível</p>
)}
{visit.message && (
<p className="mt-1 text-xs text-textTertiary line-clamp-2">{visit.message}</p>
)}
<p className="mt-2 text-xs text-textQuaternary">
{visit.scheduled_at
? `Agendada para: ${formatDate(visit.scheduled_at)}`
: `Solicitada em: ${formatDate(visit.created_at)}`
}
</p>
</div>
<span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-xs font-medium ${status.color}`}>
{status.label}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,26 @@
import { api } from './api'
import type { Agent, AgentFormData } from '../types/agent'
export async function getAgents(): Promise<Agent[]> {
const response = await api.get<Agent[]>('/agents')
return response.data
}
export async function adminGetAgents(): Promise<Agent[]> {
const response = await api.get<Agent[]>('/admin/agents')
return response.data
}
export async function adminCreateAgent(data: AgentFormData): Promise<Agent> {
const response = await api.post<Agent>('/admin/agents', data)
return response.data
}
export async function adminUpdateAgent(id: number, data: AgentFormData): Promise<Agent> {
const response = await api.put<Agent>(`/admin/agents/${id}`, data)
return response.data
}
export async function adminDeleteAgent(id: number): Promise<void> {
await api.delete(`/admin/agents/${id}`)
}

View file

@ -0,0 +1,37 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/v1',
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (
error.response?.status === 401 &&
!error.config?.url?.includes('/api/v1/auth/login') &&
!error.config?.url?.includes('/api/v1/auth/register')
) {
localStorage.removeItem('auth_token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default api
export { api }

View file

@ -0,0 +1,18 @@
import type { AuthTokenResponse, LoginCredentials, RegisterCredentials, User } from '../types/auth'
import api from './api'
export async function registerUser(data: RegisterCredentials): Promise<AuthTokenResponse> {
const { confirmPassword: _confirmPassword, ...payload } = data
const response = await api.post<AuthTokenResponse>('/auth/register', payload)
return response.data
}
export async function loginUser(data: LoginCredentials): Promise<AuthTokenResponse> {
const response = await api.post<AuthTokenResponse>('/auth/login', data)
return response.data
}
export async function getMe(): Promise<User> {
const response = await api.get<User>('/auth/me')
return response.data
}

View file

@ -0,0 +1,28 @@
import type { Amenity, City, Imobiliaria, Neighborhood, PropertyType } from '../types/catalog'
import { api } from './api'
export async function getPropertyTypes(): Promise<PropertyType[]> {
const response = await api.get<PropertyType[]>('/property-types')
return response.data
}
export async function getAmenities(): Promise<Amenity[]> {
const response = await api.get<Amenity[]>('/amenities')
return response.data
}
export async function getCities(): Promise<City[]> {
const response = await api.get<City[]>('/cities')
return response.data
}
export async function getNeighborhoods(cityId?: number): Promise<Neighborhood[]> {
const params = cityId != null ? { city_id: cityId } : {}
const response = await api.get<Neighborhood[]>('/neighborhoods', { params })
return response.data
}
export async function getImobiliarias(): Promise<Imobiliaria[]> {
const response = await api.get<Imobiliaria[]>('/imobiliarias')
return response.data
}

View file

@ -0,0 +1,25 @@
import type { Boleto, SavedProperty, VisitRequest } from '../types/clientArea';
import api from './api';
export async function getFavorites(): Promise<SavedProperty[]> {
const response = await api.get<SavedProperty[]>('/me/favorites');
return response.data;
}
export async function addFavorite(propertyId: string): Promise<void> {
await api.post('/me/favorites', { property_id: propertyId });
}
export async function removeFavorite(propertyId: string): Promise<void> {
await api.delete(`/me/favorites/${propertyId}`);
}
export async function getVisits(): Promise<VisitRequest[]> {
const response = await api.get<VisitRequest[]>('/me/visits');
return response.data;
}
export async function getBoletos(): Promise<Boleto[]> {
const response = await api.get<Boleto[]>('/me/boletos');
return response.data;
}

View file

@ -0,0 +1,36 @@
import { api } from './api'
import type { ContactFormData } from '../types/property'
// ── WhatsApp config (cached 5 min) ────────────────────────────────────────────
const DEFAULT_WHATSAPP = '5511999999999' // fallback enquanto não configurado
let _cachedNumber: string | null = null
let _cacheExpiry = 0
export async function getWhatsappNumber(): Promise<string> {
if (_cachedNumber !== null && Date.now() < _cacheExpiry) {
return _cachedNumber
}
try {
const res = await api.get<{ whatsapp_number: string }>('/config/whatsapp')
_cachedNumber = res.data.whatsapp_number?.trim() || DEFAULT_WHATSAPP
} catch {
_cachedNumber = DEFAULT_WHATSAPP
}
_cacheExpiry = Date.now() + 5 * 60 * 1000
return _cachedNumber
}
// ── Contact form ──────────────────────────────────────────────────────────────
export async function submitContactForm(
slug: string,
data: ContactFormData
): Promise<{ id: number; message: string }> {
const res = await api.post<{ id: number; message: string }>(
`/properties/${slug}/contact`,
data
)
return res.data
}

View file

@ -0,0 +1,7 @@
import type { HomepageConfig } from '../types/homepage'
import { api } from './api'
export async function getHomepageConfig(): Promise<HomepageConfig> {
const response = await api.get<HomepageConfig>('/homepage-config')
return response.data
}

View file

@ -0,0 +1,81 @@
import type { ContactFormData, PaginatedProperties, Property, PropertyDetail } from '../types/property'
import { api } from './api'
export type SortOption = 'relevance' | 'price_asc' | 'price_desc' | 'area_desc' | 'newest'
export async function getFeaturedProperties(): Promise<Property[]> {
const response = await api.get<Property[]>('/properties', {
params: { featured: 'true' },
})
return response.data
}
export interface PropertyFilters {
q?: string
sort?: SortOption
listing_type?: 'venda' | 'aluguel'
subtype_ids?: number[]
imobiliaria_id?: number
price_min?: number
price_max?: number
include_condo?: boolean
bedrooms_min?: number
bedrooms_max?: number
bathrooms_min?: number
bathrooms_max?: number
parking_min?: number
parking_max?: number
area_min?: number
area_max?: number
amenity_ids?: number[]
city_id?: number
neighborhood_ids?: number[]
page?: number
per_page?: number
}
export async function getProperties(filters: PropertyFilters = {}): Promise<PaginatedProperties> {
const params: Record<string, string | number | boolean> = {}
if (filters.q?.trim()) params.q = filters.q.trim()
if (filters.sort && filters.sort !== 'relevance') params.sort = filters.sort
if (filters.listing_type) params.listing_type = filters.listing_type
if (filters.subtype_ids?.length) params.subtype_ids = filters.subtype_ids.join(',')
if (filters.imobiliaria_id != null) params.imobiliaria_id = filters.imobiliaria_id
if (filters.price_min != null) params.price_min = filters.price_min
if (filters.price_max != null) params.price_max = filters.price_max
if (filters.include_condo) params.include_condo = 'true'
if (filters.bedrooms_min != null) params.bedrooms_min = filters.bedrooms_min
if (filters.bedrooms_max != null) params.bedrooms_max = filters.bedrooms_max
if (filters.bathrooms_min != null) params.bathrooms_min = filters.bathrooms_min
if (filters.bathrooms_max != null) params.bathrooms_max = filters.bathrooms_max
if (filters.parking_min != null) params.parking_min = filters.parking_min
if (filters.parking_max != null) params.parking_max = filters.parking_max
if (filters.area_min != null) params.area_min = filters.area_min
if (filters.area_max != null) params.area_max = filters.area_max
if (filters.amenity_ids?.length) params.amenity_ids = filters.amenity_ids.join(',')
if (filters.city_id != null) params.city_id = filters.city_id
if (filters.neighborhood_ids?.length) params.neighborhood_ids = filters.neighborhood_ids.join(',')
if (filters.page) params.page = filters.page
if (filters.per_page) params.per_page = filters.per_page
const response = await api.get<PaginatedProperties>('/properties', { params })
return response.data
}
export async function getProperty(slug: string): Promise<PropertyDetail> {
const response = await api.get<PropertyDetail>(`/properties/${slug}`)
return response.data
}
export async function submitContactForm(
slug: string,
data: ContactFormData
): Promise<{ id: number; message: string }> {
const response = await api.post<{ id: number; message: string }>(
`/properties/${slug}/contact`,
data
)
return response.data
}

View file

@ -0,0 +1,22 @@
export interface Agent {
id: number
name: string
photo_url: string | null
creci: string
email: string
phone: string
bio: string | null
is_active: boolean
display_order: number
}
export interface AgentFormData {
name: string
photo_url: string
creci: string
email: string
phone: string
bio: string
is_active: boolean
display_order: number
}

View file

@ -0,0 +1,42 @@
export interface User {
id: string;
name: string;
email: string;
role: string;
created_at: string;
}
export interface AuthTokenResponse {
access_token: string;
user: User;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterCredentials {
name: string;
email: string;
password: string;
confirmPassword: string;
// Campos opcionais (feature 012)
phone?: string;
whatsapp?: string;
cpf?: string;
birth_date?: string;
address_street?: string;
address_number?: string;
address_complement?: string;
address_neighborhood?: string;
address_city?: string;
address_state?: string;
address_zip?: string;
}
export interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
}

View file

@ -0,0 +1,43 @@
export interface PropertyType {
id: number
name: string
slug: string
parent_id: number | null
subtypes: PropertyType[]
property_count?: number
}
export interface City {
id: number
name: string
slug: string
state: string
property_count?: number
}
export interface Neighborhood {
id: number
name: string
slug: string
city_id: number
property_count?: number
}
export type AmenityGroup = 'caracteristica' | 'lazer' | 'condominio' | 'seguranca'
export interface Amenity {
id: number
name: string
slug: string
group: AmenityGroup
property_count?: number
}
export interface Imobiliaria {
id: number
name: string
logo_url: string | null
website: string | null
is_active: boolean
display_order: number
}

View file

@ -0,0 +1,30 @@
export interface VisitRequest {
id: string;
property: { id: string; title: string; slug: string } | null;
message: string | null;
status: 'pending' | 'confirmed' | 'cancelled' | 'completed';
scheduled_at: string | null;
created_at: string;
}
export interface Boleto {
id: string;
property: { id: string; title: string; slug: string } | null;
description: string;
amount: number;
due_date: string;
status: 'pending' | 'paid' | 'overdue';
url: string | null;
created_at: string;
}
export interface SavedProperty {
id: string;
property_id: string | null;
property: {
id: string;
title: string;
slug: string;
} | null;
created_at: string;
}

View file

@ -0,0 +1,8 @@
export interface HomepageConfig {
hero_headline: string
hero_subheadline: string | null
hero_cta_label: string
hero_cta_url: string
featured_properties_limit: number
hero_image_url?: string | null
}

View file

@ -0,0 +1,53 @@
import type { Amenity, City, Neighborhood, PropertyType } from './catalog'
export type { Amenity, City, Neighborhood, PropertyType }
export interface PropertyPhoto {
url: string
alt_text: string
display_order: number
}
export interface Property {
id: string
title: string
slug: string
code: string | null
address: string | null
price: string
condo_fee: string | null
iptu_anual: string | null
type: 'venda' | 'aluguel'
subtype: PropertyType | null
bedrooms: number
bathrooms: number
parking_spots: number
area_m2: number
is_featured: boolean
created_at: string | null
amenities: Amenity[]
photos: PropertyPhoto[]
city: City | null
neighborhood: Neighborhood | null
}
export interface PaginatedProperties {
items: Property[]
total: number
page: number
per_page: number
pages: number
}
export interface PropertyDetail extends Property {
address: string | null
code: string | null
description: string | null
}
export interface ContactFormData {
name: string
email: string
phone: string
message: string
}