feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
90
frontend/src/App.tsx
Normal file
90
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
55
frontend/src/components/AboutSection.tsx
Normal file
55
frontend/src/components/AboutSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
frontend/src/components/ActiveFiltersBar.tsx
Normal file
201
frontend/src/components/ActiveFiltersBar.tsx
Normal 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} m²`,
|
||||
onRemove: () => onFilterChange(omit(filters, ['area_min'])),
|
||||
})
|
||||
}
|
||||
if (filters.area_max != null) {
|
||||
chips.push({
|
||||
key: 'area_max',
|
||||
label: `Até ${filters.area_max} m²`,
|
||||
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>
|
||||
)
|
||||
}
|
||||
21
frontend/src/components/AdminRoute.tsx
Normal file
21
frontend/src/components/AdminRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
80
frontend/src/components/AgentCard.tsx
Normal file
80
frontend/src/components/AgentCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
181
frontend/src/components/AgentsCarousel.tsx
Normal file
181
frontend/src/components/AgentsCarousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/CTASection.tsx
Normal file
85
frontend/src/components/CTASection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/ComparisonBar.tsx
Normal file
65
frontend/src/components/ComparisonBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/components/ContactModal.tsx
Normal file
215
frontend/src/components/ContactModal.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/EmptyStateWithSuggestions.tsx
Normal file
57
frontend/src/components/EmptyStateWithSuggestions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/FeaturedProperties.tsx
Normal file
82
frontend/src/components/FeaturedProperties.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1019
frontend/src/components/FilterSidebar.tsx
Normal file
1019
frontend/src/components/FilterSidebar.tsx
Normal file
File diff suppressed because it is too large
Load diff
76
frontend/src/components/Footer.tsx
Normal file
76
frontend/src/components/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/HeartButton.tsx
Normal file
51
frontend/src/components/HeartButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/HeroSection.tsx
Normal file
103
frontend/src/components/HeroSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
257
frontend/src/components/HomeScrollScene.tsx
Normal file
257
frontend/src/components/HomeScrollScene.tsx
Normal 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>
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
294
frontend/src/components/Navbar.tsx
Normal file
294
frontend/src/components/Navbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
frontend/src/components/PropertyCard.tsx
Normal file
238
frontend/src/components/PropertyCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/PropertyCardSkeleton.tsx
Normal file
37
frontend/src/components/PropertyCardSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
52
frontend/src/components/PropertyDetail/AmenitiesSection.tsx
Normal file
52
frontend/src/components/PropertyDetail/AmenitiesSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
frontend/src/components/PropertyDetail/ContactSection.tsx
Normal file
175
frontend/src/components/PropertyDetail/ContactSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
frontend/src/components/PropertyDetail/PhotoCarousel.tsx
Normal file
139
frontend/src/components/PropertyDetail/PhotoCarousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/PropertyDetail/PriceBox.tsx
Normal file
53
frontend/src/components/PropertyDetail/PriceBox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/components/PropertyDetail/StatsStrip.tsx
Normal file
74
frontend/src/components/PropertyDetail/StatsStrip.tsx
Normal 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} m²`} label="Área" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
frontend/src/components/PropertyGridCard.tsx
Normal file
142
frontend/src/components/PropertyGridCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
344
frontend/src/components/PropertyRowCard.tsx
Normal file
344
frontend/src/components/PropertyRowCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/components/ProtectedRoute.tsx
Normal file
25
frontend/src/components/ProtectedRoute.tsx
Normal 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 />
|
||||
}
|
||||
27
frontend/src/components/ScrollToTopButton.tsx
Normal file
27
frontend/src/components/ScrollToTopButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/SearchBar.tsx
Normal file
80
frontend/src/components/SearchBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
frontend/src/components/ThemeToggle.tsx
Normal file
47
frontend/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
frontend/src/contexts/AuthContext.tsx
Normal file
79
frontend/src/contexts/AuthContext.tsx
Normal 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
|
||||
}
|
||||
60
frontend/src/contexts/ComparisonContext.tsx
Normal file
60
frontend/src/contexts/ComparisonContext.tsx
Normal 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;
|
||||
}
|
||||
71
frontend/src/contexts/FavoritesContext.tsx
Normal file
71
frontend/src/contexts/FavoritesContext.tsx
Normal 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;
|
||||
}
|
||||
71
frontend/src/contexts/ThemeContext.tsx
Normal file
71
frontend/src/contexts/ThemeContext.tsx
Normal 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
181
frontend/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
frontend/src/layouts/AdminLayout.tsx
Normal file
14
frontend/src/layouts/AdminLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
134
frontend/src/layouts/ClientLayout.tsx
Normal file
134
frontend/src/layouts/ClientLayout.tsx
Normal 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
13
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
259
frontend/src/pages/AboutPage.tsx
Normal file
259
frontend/src/pages/AboutPage.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
108
frontend/src/pages/AgentsPage.tsx
Normal file
108
frontend/src/pages/AgentsPage.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
77
frontend/src/pages/HomePage.tsx
Normal file
77
frontend/src/pages/HomePage.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
frontend/src/pages/LoginPage.tsx
Normal file
107
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
307
frontend/src/pages/PrivacyPolicyPage.tsx
Normal file
307
frontend/src/pages/PrivacyPolicyPage.tsx
Normal 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 nº 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 nº 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
594
frontend/src/pages/PropertiesPage.tsx
Normal file
594
frontend/src/pages/PropertiesPage.tsx
Normal 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 */ },
|
||||
}))
|
||||
}
|
||||
226
frontend/src/pages/PropertyDetailPage.tsx
Normal file
226
frontend/src/pages/PropertyDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
frontend/src/pages/RegisterPage.tsx
Normal file
220
frontend/src/pages/RegisterPage.tsx
Normal 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">
|
||||
Já tem conta?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
388
frontend/src/pages/admin/AdminAgentsPage.tsx
Normal file
388
frontend/src/pages/admin/AdminAgentsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
frontend/src/pages/admin/AdminAmenitiesPage.tsx
Normal file
147
frontend/src/pages/admin/AdminAmenitiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
330
frontend/src/pages/admin/AdminAnalyticsPage.tsx
Normal file
330
frontend/src/pages/admin/AdminAnalyticsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend/src/pages/admin/AdminBoletosPage.tsx
Normal file
178
frontend/src/pages/admin/AdminBoletosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
279
frontend/src/pages/admin/AdminCitiesPage.tsx
Normal file
279
frontend/src/pages/admin/AdminCitiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
frontend/src/pages/admin/AdminClientesPage.tsx
Normal file
261
frontend/src/pages/admin/AdminClientesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/admin/AdminFavoritosPage.tsx
Normal file
108
frontend/src/pages/admin/AdminFavoritosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
549
frontend/src/pages/admin/AdminPropertiesPage.tsx
Normal file
549
frontend/src/pages/admin/AdminPropertiesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/admin/AdminVisitasPage.tsx
Normal file
172
frontend/src/pages/admin/AdminVisitasPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/pages/admin/BoletoForm.tsx
Normal file
95
frontend/src/pages/admin/BoletoForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
frontend/src/pages/admin/ClienteForm.tsx
Normal file
269
frontend/src/pages/admin/ClienteForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
745
frontend/src/pages/admin/PropertyForm.tsx
Normal file
745
frontend/src/pages/admin/PropertyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/src/pages/admin/VisitaForm.tsx
Normal file
73
frontend/src/pages/admin/VisitaForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/pages/client/BoletosPage.tsx
Normal file
100
frontend/src/pages/client/BoletosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/pages/client/ClientDashboardPage.tsx
Normal file
53
frontend/src/pages/client/ClientDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/pages/client/ComparisonPage.tsx
Normal file
97
frontend/src/pages/client/ComparisonPage.tsx
Normal 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} m²` : '—' },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/pages/client/FavoritesPage.tsx
Normal file
64
frontend/src/pages/client/FavoritesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/client/VisitsPage.tsx
Normal file
90
frontend/src/pages/client/VisitsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/services/agents.ts
Normal file
26
frontend/src/services/agents.ts
Normal 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}`)
|
||||
}
|
||||
37
frontend/src/services/api.ts
Normal file
37
frontend/src/services/api.ts
Normal 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 }
|
||||
|
||||
18
frontend/src/services/auth.ts
Normal file
18
frontend/src/services/auth.ts
Normal 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
|
||||
}
|
||||
28
frontend/src/services/catalog.ts
Normal file
28
frontend/src/services/catalog.ts
Normal 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
|
||||
}
|
||||
25
frontend/src/services/clientArea.ts
Normal file
25
frontend/src/services/clientArea.ts
Normal 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;
|
||||
}
|
||||
36
frontend/src/services/contact.ts
Normal file
36
frontend/src/services/contact.ts
Normal 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
|
||||
}
|
||||
7
frontend/src/services/homepage.ts
Normal file
7
frontend/src/services/homepage.ts
Normal 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
|
||||
}
|
||||
81
frontend/src/services/properties.ts
Normal file
81
frontend/src/services/properties.ts
Normal 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
|
||||
}
|
||||
|
||||
22
frontend/src/types/agent.ts
Normal file
22
frontend/src/types/agent.ts
Normal 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
|
||||
}
|
||||
42
frontend/src/types/auth.ts
Normal file
42
frontend/src/types/auth.ts
Normal 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;
|
||||
}
|
||||
43
frontend/src/types/catalog.ts
Normal file
43
frontend/src/types/catalog.ts
Normal 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
|
||||
}
|
||||
30
frontend/src/types/clientArea.ts
Normal file
30
frontend/src/types/clientArea.ts
Normal 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;
|
||||
}
|
||||
8
frontend/src/types/homepage.ts
Normal file
8
frontend/src/types/homepage.ts
Normal 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
|
||||
}
|
||||
53
frontend/src/types/property.ts
Normal file
53
frontend/src/types/property.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue