feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint
migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import AdminRoute from './components/AdminRoute';
|
||||
import ComparisonBar from './components/ComparisonBar';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
|
@ -8,28 +8,35 @@ import { ComparisonProvider } from './contexts/ComparisonContext';
|
|||
import { FavoritesProvider } from './contexts/FavoritesContext';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import ClientLayout from './layouts/ClientLayout';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
import AgentsPage from './pages/AgentsPage';
|
||||
import CadastroResidenciaPage from './pages/CadastroResidenciaPage'
|
||||
import JobsPage from './pages/JobsPage';
|
||||
import ContactPage from './pages/ContactPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
|
||||
import PropertiesPage from './pages/PropertiesPage';
|
||||
import PropertyDetailPage from './pages/PropertyDetailPage';
|
||||
import PublicFavoritesPage from './pages/PublicFavoritesPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import AgentsPage from './pages/AgentsPage';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
import PrivacyPolicyPage from './pages/PrivacyPolicyPage';
|
||||
import AdminAmenitiesPage from './pages/admin/AdminAmenitiesPage';
|
||||
import AdminAgentsPage from './pages/admin/AdminAgentsPage';
|
||||
import AdminAmenitiesPage from './pages/admin/AdminAmenitiesPage';
|
||||
import AdminAnalyticsPage from './pages/admin/AdminAnalyticsPage';
|
||||
import AdminBoletosPage from './pages/admin/AdminBoletosPage';
|
||||
import AdminCitiesPage from './pages/admin/AdminCitiesPage';
|
||||
import AdminClientesPage from './pages/admin/AdminClientesPage';
|
||||
import AdminContactConfigPage from './pages/admin/AdminContactConfigPage';
|
||||
import AdminFavoritosPage from './pages/admin/AdminFavoritosPage';
|
||||
import AdminLeadsPage from './pages/admin/AdminLeadsPage'
|
||||
import AdminJobsPage from './pages/admin/AdminJobsPage';
|
||||
import AdminPropertiesPage from './pages/admin/AdminPropertiesPage';
|
||||
import AdminVisitasPage from './pages/admin/AdminVisitasPage';
|
||||
import BoletosPage from './pages/client/BoletosPage';
|
||||
import ClientDashboardPage from './pages/client/ClientDashboardPage';
|
||||
import AdminHomepageConfigPage from './pages/admin/AdminHomepageConfigPage';
|
||||
import ComparisonPage from './pages/client/ComparisonPage';
|
||||
import FavoritesPage from './pages/client/FavoritesPage';
|
||||
import VisitsPage from './pages/client/VisitsPage';
|
||||
import ProfilePage from './pages/client/ProfilePage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -44,6 +51,10 @@ export default function App() {
|
|||
<Route path="/corretores" element={<AgentsPage />} />
|
||||
<Route path="/sobre" element={<AboutPage />} />
|
||||
<Route path="/politica-de-privacidade" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/favoritos" element={<PublicFavoritesPage />} />
|
||||
<Route path="/contato" element={<ContactPage />} />
|
||||
<Route path="/cadastro-residencia" element={<CadastroResidenciaPage />} />
|
||||
<Route path="/trabalhe-conosco" element={<JobsPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/cadastro" element={<RegisterPage />} />
|
||||
<Route
|
||||
|
|
@ -54,11 +65,11 @@ export default function App() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<ClientDashboardPage />} />
|
||||
<Route index element={<Navigate to="favoritos" replace />} />
|
||||
<Route path="favoritos" element={<FavoritesPage />} />
|
||||
<Route path="comparar" element={<ComparisonPage />} />
|
||||
<Route path="visitas" element={<VisitsPage />} />
|
||||
<Route path="boletos" element={<BoletosPage />} />
|
||||
<Route path="conta" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/admin"
|
||||
|
|
@ -78,6 +89,10 @@ export default function App() {
|
|||
<Route path="amenidades" element={<AdminAmenitiesPage />} />
|
||||
<Route path="corretores" element={<AdminAgentsPage />} />
|
||||
<Route path="analytics" element={<AdminAnalyticsPage />} />
|
||||
<Route path="leads" element={<AdminLeadsPage />} />
|
||||
<Route path="candidaturas" element={<AdminJobsPage />} />
|
||||
<Route path="contato-config" element={<AdminContactConfigPage />} />
|
||||
<Route path="homepage-config" element={<AdminHomepageConfigPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ComparisonBar />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getAgents } from '../services/agents'
|
||||
import type { Agent } from '../types/agent'
|
||||
|
||||
const AUTOPLAY_INTERVAL = 3500
|
||||
|
|
@ -76,21 +75,17 @@ function SkeletonSlide() {
|
|||
)
|
||||
}
|
||||
|
||||
export default function AgentsCarousel() {
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
interface AgentsCarouselProps {
|
||||
agents: Agent[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export default function AgentsCarousel({ agents, loading }: AgentsCarouselProps) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const autoplayRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getAgents()
|
||||
.then(setAgents)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Duplicate agents for infinite-like feel
|
||||
const slides = agents.length > 0 ? [...agents, ...agents] : []
|
||||
const total = agents.length
|
||||
|
|
@ -130,7 +125,7 @@ export default function AgentsCarousel() {
|
|||
<div
|
||||
ref={trackRef}
|
||||
className="flex gap-5 transition-transform duration-500 ease-in-out py-2"
|
||||
style={{ transform: `translateX(-${offset}px)` }}
|
||||
style={{ transform: `translateX(-${offset}px)`, willChange: 'transform' }}
|
||||
>
|
||||
{slides.map((agent, i) => (
|
||||
<AgentSlide key={`${agent.id}-${i}`} agent={agent} />
|
||||
|
|
|
|||
95
frontend/src/components/FavoritesCardsGrid.tsx
Normal file
95
frontend/src/components/FavoritesCardsGrid.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import HeartButton from './HeartButton';
|
||||
|
||||
export interface FavoriteCardEntry {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
price: string;
|
||||
type: 'venda' | 'aluguel';
|
||||
photo: string | null;
|
||||
city: string | null;
|
||||
bedrooms: number;
|
||||
area_m2: number;
|
||||
}
|
||||
|
||||
function formatPrice(price: string, type: 'venda' | 'aluguel') {
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
const formatted = num.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 });
|
||||
return type === 'aluguel' ? `${formatted}/mês` : formatted;
|
||||
}
|
||||
|
||||
interface FavoritesCardsGridProps {
|
||||
entries: FavoriteCardEntry[];
|
||||
}
|
||||
|
||||
export default function FavoritesCardsGrid({ entries }: FavoritesCardsGridProps) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-16 text-center">
|
||||
<svg className="mx-auto mb-4 text-textTertiary" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
<p className="text-textTertiary mb-4">Nenhum favorito ainda</p>
|
||||
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
|
||||
Explorar imóveis →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{entries.map(entry => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="relative rounded-xl border border-borderSubtle bg-panel overflow-hidden hover:border-borderStandard transition group"
|
||||
>
|
||||
<div className="relative h-40 bg-surface">
|
||||
{entry.photo ? (
|
||||
<img
|
||||
src={entry.photo}
|
||||
alt={entry.title}
|
||||
className="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-textTertiary">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<HeartButton propertyId={entry.id} />
|
||||
</div>
|
||||
<span className={`absolute bottom-2 left-2 rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm ${entry.type === 'venda' ? 'bg-brand/80 text-white' : 'bg-black/50 text-white/90 border border-white/20'}`}>
|
||||
{entry.type === 'venda' ? 'Venda' : 'Aluguel'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Link to={entry.slug ? `/imoveis/${entry.slug}` : '#'} className="block">
|
||||
<p className="text-sm font-semibold text-textPrimary line-clamp-2 leading-snug">
|
||||
{entry.title}
|
||||
</p>
|
||||
{entry.city && (
|
||||
<p className="text-xs text-textTertiary mt-1 truncate">{entry.city}</p>
|
||||
)}
|
||||
{entry.price && (
|
||||
<p className="text-sm font-semibold text-textPrimary mt-2">
|
||||
{formatPrice(entry.price, entry.type)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 mt-2 text-xs text-textTertiary">
|
||||
{entry.bedrooms > 0 && <span>{entry.bedrooms} qto{entry.bedrooms !== 1 ? 's' : ''}</span>}
|
||||
{entry.area_m2 > 0 && <span>{entry.area_m2} m²</span>}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { PropertyFilters } from '../services/properties'
|
||||
import type { Amenity, AmenityGroup, City, Imobiliaria, Neighborhood, PropertyType } from '../types/catalog'
|
||||
|
||||
|
|
@ -406,7 +406,7 @@ function PriceRange({
|
|||
className={`h-7 px-2.5 rounded-md text-xs border transition-all duration-150 ${maxValue === p.value
|
||||
? 'bg-brand/15 border-brand/40 text-brand font-medium'
|
||||
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
|
|
@ -490,7 +490,7 @@ function MinChipRow({
|
|||
className={`h-8 min-w-[40px] px-2.5 rounded-lg text-xs font-medium border transition-all duration-150 ${isActive
|
||||
? 'bg-brand/15 border-brand/40 text-brand'
|
||||
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{opt}{suffix}
|
||||
</button>
|
||||
|
|
@ -510,7 +510,7 @@ function AmenityCheck({ name, checked, onToggle }: { name: string; checked: bool
|
|||
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-all duration-150 ${checked
|
||||
? 'bg-brand border-brand'
|
||||
: 'bg-transparent border-borderStandard group-hover/item:border-brand/50'
|
||||
}`}
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && (
|
||||
|
|
@ -697,7 +697,7 @@ export default function FilterSidebar({
|
|||
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 ${isActive
|
||||
? 'bg-panel text-textPrimary shadow-sm'
|
||||
: 'text-textTertiary hover:text-textSecondary'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
|
@ -733,7 +733,7 @@ export default function FilterSidebar({
|
|||
open={openSections['imobiliaria']}
|
||||
onToggle={() => toggleSection('imobiliaria')}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{imobiliarias.map(imob => {
|
||||
const isActive = filters.imobiliaria_id === imob.id
|
||||
return (
|
||||
|
|
@ -741,12 +741,15 @@ export default function FilterSidebar({
|
|||
key={imob.id}
|
||||
onClick={() => set({ imobiliaria_id: isActive ? undefined : imob.id })}
|
||||
aria-pressed={isActive}
|
||||
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
|
||||
? 'bg-brand/15 border-brand/40 text-brand font-medium'
|
||||
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
|
||||
}`}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
|
||||
? 'bg-brand/10 text-brand font-medium'
|
||||
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
|
||||
}`}
|
||||
>
|
||||
{imob.name}
|
||||
<span>{imob.name}</span>
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -762,9 +765,9 @@ export default function FilterSidebar({
|
|||
open={openSections['localizacao']}
|
||||
onToggle={() => toggleSection('localizacao')}
|
||||
>
|
||||
{/* City — chips when ≤ 5, select when more */}
|
||||
{/* City — list full-width */}
|
||||
{cities.length <= 5 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<div className="flex flex-col gap-0.5 mb-3">
|
||||
{cities.map(city => {
|
||||
const isActive = filters.city_id === city.id
|
||||
return (
|
||||
|
|
@ -772,13 +775,15 @@ export default function FilterSidebar({
|
|||
key={city.id}
|
||||
onClick={() => set({ city_id: isActive ? undefined : city.id, neighborhood_ids: undefined })}
|
||||
aria-pressed={isActive}
|
||||
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
|
||||
? 'bg-brand/15 border-brand/40 text-brand font-medium'
|
||||
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
|
||||
}`}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
|
||||
? 'bg-brand/10 text-brand font-medium'
|
||||
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
|
||||
}`}
|
||||
>
|
||||
{city.name}
|
||||
<span className="ml-1 opacity-50">{city.state}</span>
|
||||
<span>{city.name}<span className="ml-1 opacity-50">{city.state}</span></span>
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
@ -817,19 +822,22 @@ export default function FilterSidebar({
|
|||
renderItem={(nbh, isPopular) => {
|
||||
const isActive = (filters.neighborhood_ids ?? []).includes(nbh.id)
|
||||
return (
|
||||
<span className="inline-block mb-1.5 mr-1.5">
|
||||
<button
|
||||
onClick={() => toggleNeighborhood(nbh.id)}
|
||||
aria-pressed={isActive}
|
||||
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
|
||||
? 'bg-brand/15 border-brand/40 text-brand font-medium'
|
||||
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
|
||||
<button
|
||||
onClick={() => toggleNeighborhood(nbh.id)}
|
||||
aria-pressed={isActive}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
|
||||
? 'bg-brand/10 text-brand font-medium'
|
||||
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
|
||||
}`}
|
||||
>
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{nbh.name}
|
||||
{isPopular && <PopularBadge />}
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
|
@ -879,19 +887,22 @@ export default function FilterSidebar({
|
|||
renderItem={(sub, isPopular) => {
|
||||
const isActive = (filters.subtype_ids ?? []).includes(sub.id)
|
||||
return (
|
||||
<span className="inline-block mb-1.5 mr-1.5">
|
||||
<button
|
||||
onClick={() => toggleSubtype(sub.id)}
|
||||
aria-pressed={isActive}
|
||||
className={`text-xs px-2.5 py-1 rounded-md border transition-all duration-150 ${isActive
|
||||
? 'bg-brand/15 border-brand/40 text-brand font-medium'
|
||||
: 'border-borderSubtle text-textTertiary hover:border-borderStandard hover:text-textSecondary'
|
||||
<button
|
||||
onClick={() => toggleSubtype(sub.id)}
|
||||
aria-pressed={isActive}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-all duration-150 ${isActive
|
||||
? 'bg-brand/10 text-brand font-medium'
|
||||
: 'text-textSecondary hover:bg-surface hover:text-textPrimary'
|
||||
}`}
|
||||
>
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{sub.name}
|
||||
{isPopular && <PopularBadge />}
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
{isActive && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
|
@ -919,7 +930,7 @@ export default function FilterSidebar({
|
|||
className={`w-4 h-4 rounded flex-shrink-0 flex items-center justify-center border transition-all duration-150 ${filters.include_condo
|
||||
? 'bg-brand border-brand'
|
||||
: 'bg-transparent border-borderStandard group-hover/condo:border-brand/50'
|
||||
}`}
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{filters.include_condo && (
|
||||
|
|
|
|||
|
|
@ -1,74 +1,148 @@
|
|||
const footerLinks = [
|
||||
{ label: 'Imóveis', href: '/imoveis' },
|
||||
{ label: 'Sobre', href: '/sobre' },
|
||||
{ label: 'Contato', href: '#contato' },
|
||||
{ label: 'Política de Privacidade', href: '/politica-de-privacidade' },
|
||||
]
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
function FooterColumn({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-[10px] font-semibold tracking-widest uppercase text-textQuaternary">
|
||||
{title}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-2 list-none m-0 p-0">
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterLink({ to, href, children }: { to?: string; href?: string; children: React.ReactNode }) {
|
||||
const cls = 'text-sm text-textTertiary hover:text-textSecondary transition-colors duration-150'
|
||||
if (to) return <li><Link to={to} className={cls}>{children}</Link></li>
|
||||
return <li><a href={href} className={cls}>{children}</a></li>
|
||||
}
|
||||
|
||||
function InstagramIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" /><circle cx="12" cy="12" r="4" /><circle cx="17.5" cy="6.5" r="0.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function FacebookIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function WhatsAppIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
className="bg-panel border-t border-borderSubtle py-10 px-6"
|
||||
className="bg-panel border-t border-borderSubtle"
|
||||
>
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-8">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-5 h-5 bg-brand-indigo rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
{/* Main grid */}
|
||||
<div className="max-w-[1200px] mx-auto px-6 py-12">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-10">
|
||||
|
||||
{/* Brand — ocupa 2 colunas no lg */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 w-fit"
|
||||
aria-label="ImobiliáriaHub — Página inicial"
|
||||
>
|
||||
<span className="w-6 h-6 bg-brand-indigo rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
I
|
||||
</span>
|
||||
<span className="text-textPrimary font-semibold text-sm">
|
||||
ImobiliáriaHub
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-textTertiary max-w-[260px] leading-relaxed">
|
||||
Conectando pessoas aos melhores imóveis da região desde 2014.
|
||||
</Link>
|
||||
<p className="text-sm text-textTertiary max-w-[280px] leading-relaxed">
|
||||
Conectamos você ao imóvel ideal com segurança, transparência e agilidade.
|
||||
</p>
|
||||
{/* Redes sociais */}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<a
|
||||
href="https://instagram.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
className="text-textQuaternary hover:text-textSecondary transition-colors duration-150"
|
||||
>
|
||||
<InstagramIcon />
|
||||
</a>
|
||||
<a
|
||||
href="https://facebook.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Facebook"
|
||||
className="text-textQuaternary hover:text-textSecondary transition-colors duration-150"
|
||||
>
|
||||
<FacebookIcon />
|
||||
</a>
|
||||
<a
|
||||
href="https://wa.me/5511999999999"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="WhatsApp"
|
||||
className="text-textQuaternary hover:text-textSecondary transition-colors duration-150"
|
||||
>
|
||||
<WhatsAppIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav aria-label="Rodapé — navegação">
|
||||
<ul className="flex flex-wrap gap-x-6 gap-y-2 list-none m-0 p-0">
|
||||
{footerLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-xs text-textTertiary hover:text-textSecondary transition-colors duration-150"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
{/* Institucional */}
|
||||
<FooterColumn title="A Imobiliária">
|
||||
<FooterLink to="/sobre">Quem somos</FooterLink>
|
||||
<FooterLink to="/corretores">Equipe</FooterLink>
|
||||
<FooterLink to="/trabalhe-conosco">Trabalhe Conosco</FooterLink>
|
||||
<FooterLink to="/contato">Fale conosco</FooterLink>
|
||||
<FooterLink to="/politica-de-privacidade">Política de Privacidade</FooterLink>
|
||||
</FooterColumn>
|
||||
|
||||
{/* Imóveis */}
|
||||
<FooterColumn title="Imóveis">
|
||||
<FooterLink to="/imoveis?listing_type=venda">Imóveis para comprar</FooterLink>
|
||||
<FooterLink to="/imoveis?listing_type=aluguel">Imóveis para alugar</FooterLink>
|
||||
<FooterLink to="/cadastro-residencia">Anunciar seu imóvel</FooterLink>
|
||||
<FooterLink to="/favoritos">Favoritos</FooterLink>
|
||||
</FooterColumn>
|
||||
|
||||
{/* Atendimento */}
|
||||
<FooterColumn title="Atendimento">
|
||||
<FooterLink href="tel:+5511999999999">(11) 99999-9999</FooterLink>
|
||||
<FooterLink href="mailto:contato@imobiliariahub.com.br">contato@imobiliariahub.com.br</FooterLink>
|
||||
<li className="text-sm text-textTertiary leading-relaxed">
|
||||
Rua Exemplo, 1000 — Centro<br />CEP: 01310-100
|
||||
</li>
|
||||
</FooterColumn>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<a
|
||||
href="tel:+5511999999999"
|
||||
className="text-xs text-textTertiary hover:text-textSecondary transition-colors duration-150"
|
||||
aria-label="Telefone: (11) 99999-9999"
|
||||
>
|
||||
(11) 99999-9999
|
||||
</a>
|
||||
<a
|
||||
href="mailto:contato@imobiliariahub.com.br"
|
||||
className="text-xs text-textTertiary hover:text-textSecondary transition-colors duration-150"
|
||||
aria-label="E-mail: contato@imobiliariahub.com.br"
|
||||
>
|
||||
contato@imobiliariahub.com.br
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-borderSubtle">
|
||||
<p className="text-xs text-textQuaternary text-center">
|
||||
{/* Bottom bar */}
|
||||
<div className="border-t border-borderSubtle">
|
||||
<div className="max-w-[1200px] mx-auto px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<p className="text-xs text-textQuaternary">
|
||||
© {currentYear} ImobiliáriaHub. Todos os direitos reservados.
|
||||
</p>
|
||||
<Link
|
||||
to="/politica-de-privacidade"
|
||||
className="text-xs text-textQuaternary hover:text-textTertiary transition-colors duration-150"
|
||||
>
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,20 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { LocalFavoriteEntry } from '../contexts/FavoritesContext';
|
||||
import { useFavorites } from '../contexts/FavoritesContext';
|
||||
|
||||
interface HeartButtonProps {
|
||||
propertyId: string;
|
||||
snapshot?: LocalFavoriteEntry;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function HeartButton({ propertyId, className = '' }: HeartButtonProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
export default function HeartButton({ propertyId, snapshot, className = '' }: HeartButtonProps) {
|
||||
const { favoriteIds, toggle } = useFavorites();
|
||||
const navigate = useNavigate();
|
||||
const isFav = favoriteIds.has(propertyId);
|
||||
|
||||
async function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
await toggle(propertyId);
|
||||
await toggle(propertyId, snapshot);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -28,8 +22,8 @@ export default function HeartButton({ propertyId, className = '' }: HeartButtonP
|
|||
onClick={handleClick}
|
||||
aria-label={isFav ? 'Remover dos favoritos' : 'Adicionar aos favoritos'}
|
||||
className={`rounded-full p-1.5 transition-colors ${isFav
|
||||
? 'text-red-400 hover:text-red-300'
|
||||
: 'text-white/40 hover:text-white/70'
|
||||
? 'text-red-400 hover:text-red-300'
|
||||
: 'text-white/40 hover:text-white/70'
|
||||
} ${className}`}
|
||||
>
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -1,35 +1,19 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import PropertyRowCard from './PropertyRowCard'
|
||||
import { getFeaturedProperties } from '../services/properties'
|
||||
import type React from 'react'
|
||||
import type { Property } from '../types/property'
|
||||
import PropertyRowCard from './PropertyRowCard'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useInView } from '../hooks/useInView'
|
||||
|
||||
// ── Card com animação de entrada ao rolar ─────────────────────────────────────
|
||||
|
||||
function RiseCard({ children, index }: { children: React.ReactNode; index: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.05 }
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const { ref, inView } = useInView({ threshold: 0.05 })
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ transitionDelay: `${Math.min(index * 60, 240)}ms` }}
|
||||
className={`transition-all duration-700 ease-out ${visible
|
||||
className={`transition-all duration-700 ease-out ${inView
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-12'
|
||||
}`}
|
||||
|
|
@ -62,15 +46,17 @@ function RowSkeleton() {
|
|||
|
||||
// ── Scroll hint (seta animada) ────────────────────────────────────────────────
|
||||
|
||||
function ScrollHint({ label }: { label: string }) {
|
||||
function ScrollHint({ label, isLight }: { label: string; isLight?: boolean }) {
|
||||
return (
|
||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 select-none pointer-events-none">
|
||||
<span className="text-white/40 text-[11px] tracking-[0.2em] uppercase font-medium">{label}</span>
|
||||
<span className={`text-[11px] tracking-[0.2em] uppercase font-medium ${
|
||||
isLight ? 'text-[#3a3f6e]/50' : 'text-white/40'
|
||||
}`}>{label}</span>
|
||||
<div className="flex flex-col items-center gap-0.5 opacity-40">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<svg
|
||||
key={i}
|
||||
className="w-3.5 h-3.5 text-white"
|
||||
className={`w-3.5 h-3.5 ${isLight ? 'text-[#3a3f6e]' : 'text-white'}`}
|
||||
style={{ animation: `fadeDown 1.4s ease-in-out ${i * 0.2}s infinite` }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -93,6 +79,8 @@ interface HomeScrollSceneProps {
|
|||
ctaUrl: string
|
||||
backgroundImage?: string | null
|
||||
isLoading?: boolean
|
||||
properties: Property[]
|
||||
loadingProperties: boolean
|
||||
}
|
||||
|
||||
export default function HomeScrollScene({
|
||||
|
|
@ -102,27 +90,14 @@ export default function HomeScrollScene({
|
|||
ctaUrl,
|
||||
backgroundImage,
|
||||
isLoading = false,
|
||||
properties,
|
||||
loadingProperties,
|
||||
}: HomeScrollSceneProps) {
|
||||
const [properties, setProperties] = useState<Property[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getFeaturedProperties()
|
||||
.then(setProperties)
|
||||
.catch(() => { })
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isLight = resolvedTheme === 'light'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Keyframes inline para as setas e fade */}
|
||||
<style>{`
|
||||
@keyframes fadeDown {
|
||||
0%, 100% { opacity: 0; transform: translateY(-4px); }
|
||||
50% { opacity: 1; transform: translateY(4px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="relative">
|
||||
{/* ── Imagem de fundo sticky ───────────────────────────────────── */}
|
||||
<div className="sticky top-0 h-screen z-0 overflow-hidden">
|
||||
|
|
@ -131,18 +106,28 @@ export default function HomeScrollScene({
|
|||
src={backgroundImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: [
|
||||
'radial-gradient(ellipse 90% 70% at 20% 45%, rgba(94,106,210,0.22) 0%, transparent 60%)',
|
||||
'radial-gradient(ellipse 60% 50% at 80% 25%, rgba(94,106,210,0.10) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse 80% 60% at 50% 90%, rgba(10,8,20,0.8) 0%, transparent 70%)',
|
||||
'linear-gradient(135deg, #0d0e14 0%, #08090a 55%, #0b0c10 100%)',
|
||||
].join(','),
|
||||
background: isLight
|
||||
? [
|
||||
'radial-gradient(ellipse 90% 70% at 20% 45%, rgba(94,106,210,0.28) 0%, transparent 60%)',
|
||||
'radial-gradient(ellipse 60% 50% at 80% 25%, rgba(94,106,210,0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse 80% 60% at 50% 90%, rgba(180,190,255,0.55) 0%, transparent 70%)',
|
||||
'linear-gradient(135deg, #dde0f7 0%, #eaedff 55%, #e2e5f8 100%)',
|
||||
].join(',')
|
||||
: [
|
||||
'radial-gradient(ellipse 90% 70% at 20% 45%, rgba(94,106,210,0.22) 0%, transparent 60%)',
|
||||
'radial-gradient(ellipse 60% 50% at 80% 25%, rgba(94,106,210,0.10) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse 80% 60% at 50% 90%, rgba(10,8,20,0.8) 0%, transparent 70%)',
|
||||
'linear-gradient(135deg, #0d0e14 0%, #08090a 55%, #0b0c10 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -151,8 +136,9 @@ export default function HomeScrollScene({
|
|||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(8,9,10,0.25) 0%, rgba(8,9,10,0) 30%, rgba(8,9,10,0.5) 80%, rgba(8,9,10,0.95) 100%)',
|
||||
background: isLight
|
||||
? 'linear-gradient(to bottom, rgba(216,220,255,0.2) 0%, rgba(216,220,255,0) 30%, rgba(210,215,248,0.75) 80%, rgba(205,210,245,0.98) 100%)'
|
||||
: 'linear-gradient(to bottom, rgba(8,9,10,0.25) 0%, rgba(8,9,10,0) 30%, rgba(8,9,10,0.5) 80%, rgba(8,9,10,0.95) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -160,20 +146,28 @@ export default function HomeScrollScene({
|
|||
<div className="absolute inset-0 flex items-center justify-center z-10 px-6 pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center max-w-[720px] w-full space-y-4 animate-pulse">
|
||||
<div className="h-12 bg-white/10 rounded-xl w-4/5 mx-auto" />
|
||||
<div className="h-6 bg-white/10 rounded-xl w-3/5 mx-auto" />
|
||||
<div className="h-11 bg-white/10 rounded-full w-36 mx-auto mt-6" />
|
||||
<div className={`h-12 rounded-xl w-4/5 mx-auto ${isLight ? 'bg-indigo-200/50' : 'bg-white/10'}`} />
|
||||
<div className={`h-6 rounded-xl w-3/5 mx-auto ${isLight ? 'bg-indigo-200/50' : 'bg-white/10'}`} />
|
||||
<div className={`h-11 rounded-full w-36 mx-auto mt-6 ${isLight ? 'bg-indigo-200/50' : 'bg-white/10'}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center max-w-[720px] w-full">
|
||||
<h1
|
||||
className="text-[36px] md:text-[52px] lg:text-[68px] font-semibold leading-[1.08] tracking-tight text-white drop-shadow-[0_2px_24px_rgba(0,0,0,0.6)]"
|
||||
className={`text-[36px] md:text-[52px] lg:text-[68px] font-semibold leading-[1.08] tracking-tight ${
|
||||
isLight
|
||||
? 'text-[#1a1d3a] drop-shadow-[0_1px_12px_rgba(94,106,210,0.18)]'
|
||||
: 'text-white drop-shadow-[0_2px_24px_rgba(0,0,0,0.6)]'
|
||||
}`}
|
||||
style={{ fontFeatureSettings: '"cv01","ss03"' }}
|
||||
>
|
||||
{headline}
|
||||
</h1>
|
||||
{subheadline && (
|
||||
<p className="mt-4 text-base md:text-lg text-white/75 max-w-[560px] mx-auto leading-relaxed drop-shadow-[0_1px_8px_rgba(0,0,0,0.5)]">
|
||||
<p className={`mt-4 text-base md:text-lg max-w-[560px] mx-auto leading-relaxed ${
|
||||
isLight
|
||||
? 'text-[#3a3f6e]/80 drop-shadow-none'
|
||||
: 'text-white/75 drop-shadow-[0_1px_8px_rgba(0,0,0,0.5)]'
|
||||
}`}>
|
||||
{subheadline}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -191,7 +185,7 @@ export default function HomeScrollScene({
|
|||
</div>
|
||||
|
||||
{/* Indicador de rolar */}
|
||||
<ScrollHint label="Imóveis em destaque" />
|
||||
<ScrollHint label="Imóveis em destaque" isLight={isLight} />
|
||||
</div>
|
||||
|
||||
{/* ── Seção de imóveis que sobe sobre a imagem ─────────────────── */}
|
||||
|
|
@ -200,30 +194,36 @@ export default function HomeScrollScene({
|
|||
<div
|
||||
className="h-48 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom, transparent 0%, #08090a 100%)',
|
||||
background: isLight
|
||||
? 'linear-gradient(to bottom, transparent 0%, #dde0f7 100%)'
|
||||
: 'linear-gradient(to bottom, transparent 0%, #08090a 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="pb-40"
|
||||
style={{ background: '#08090a' }}
|
||||
style={{ background: isLight ? '#dde0f7' : '#08090a' }}
|
||||
>
|
||||
{/* Cabeçalho da seção */}
|
||||
<div className="max-w-[980px] mx-auto px-6 pb-8">
|
||||
<h2
|
||||
className="text-2xl md:text-3xl font-medium text-textPrimary tracking-tight"
|
||||
className={`text-2xl md:text-3xl font-medium tracking-tight ${
|
||||
isLight ? 'text-[#1a1d3a]' : 'text-textPrimary'
|
||||
}`}
|
||||
style={{ fontFeatureSettings: '"cv01", "ss03"' }}
|
||||
>
|
||||
Imóveis em Destaque
|
||||
</h2>
|
||||
<p className="mt-1.5 text-textSecondary text-sm">
|
||||
<p className={`mt-1.5 text-sm ${
|
||||
isLight ? 'text-[#3a3f6e]/70' : 'text-textSecondary'
|
||||
}`}>
|
||||
Selecionados especialmente para você
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="max-w-[980px] mx-auto px-6 flex flex-col gap-4">
|
||||
{loading
|
||||
{loadingProperties
|
||||
? Array.from({ length: 3 }).map((_, i) => <RowSkeleton key={i} />)
|
||||
: properties.map((p, i) => (
|
||||
<RiseCard key={p.id} index={i}>
|
||||
|
|
@ -234,7 +234,7 @@ export default function HomeScrollScene({
|
|||
</div>
|
||||
|
||||
{/* CTA direto para /imoveis */}
|
||||
{!loading && (
|
||||
{!loadingProperties && (
|
||||
<div className="max-w-[980px] mx-auto px-6 mt-16 flex flex-col items-center gap-4">
|
||||
<a
|
||||
href="/imoveis"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,35 @@
|
|||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, NavLink, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useFavorites } from '../contexts/FavoritesContext'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Imóveis', href: '/imoveis', internal: true },
|
||||
{ label: 'Corretores', href: '/corretores', internal: true },
|
||||
{ label: 'Sobre', href: '#sobre', internal: false },
|
||||
{ label: 'Contato', href: '#contato', internal: false },
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Overlay = 'closed' | 'mobile' | 'user' | 'admin'
|
||||
|
||||
// ─── Navigation config ────────────────────────────────────────────────────────
|
||||
|
||||
interface NavLinkDef {
|
||||
label: string
|
||||
href: string
|
||||
/** pathname prefix/exact for active matching when href has query params */
|
||||
matchPath?: string
|
||||
matchQuery?: Record<string, string>
|
||||
}
|
||||
|
||||
const publicNavLinks: NavLinkDef[] = [
|
||||
{ label: 'Início', href: '/' },
|
||||
{ label: 'Comprar', href: '/imoveis?listing_type=venda', matchPath: '/imoveis', matchQuery: { listing_type: 'venda' } },
|
||||
{ label: 'Alugar', href: '/imoveis?listing_type=aluguel', matchPath: '/imoveis', matchQuery: { listing_type: 'aluguel' } },
|
||||
{ label: 'Equipe', href: '/corretores' },
|
||||
{ label: 'Sobre', href: '/sobre' },
|
||||
{ label: 'Contato', href: '/contato' },
|
||||
]
|
||||
|
||||
const adminNavItems = [
|
||||
interface AdminNavItem { to: string; label: string }
|
||||
const adminNavItems: AdminNavItem[] = [
|
||||
{ to: '/admin/properties', label: 'Imóveis' },
|
||||
{ to: '/admin/corretores', label: 'Corretores' },
|
||||
{ to: '/admin/clientes', label: 'Clientes' },
|
||||
|
|
@ -21,51 +39,177 @@ const adminNavItems = [
|
|||
{ to: '/admin/cidades', label: 'Cidades' },
|
||||
{ to: '/admin/amenidades', label: 'Amenidades' },
|
||||
{ to: '/admin/analytics', label: 'Analytics' },
|
||||
{ to: '/admin/leads', label: 'Leads' },
|
||||
{ to: '/admin/candidaturas', label: 'Candidaturas' },
|
||||
{ to: '/admin/homepage-config', label: 'Conf. Home' },
|
||||
{ to: '/admin/contato-config', label: 'Conf. Contato' },
|
||||
]
|
||||
|
||||
const clientNavItems = [
|
||||
{ to: '/area-do-cliente', label: 'Painel', end: true },
|
||||
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false },
|
||||
interface ClientNavItem { to: string; label: string; end: boolean }
|
||||
const clientNavItems: ClientNavItem[] = [
|
||||
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false },
|
||||
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false },
|
||||
{ to: '/area-do-cliente/boletos', label: 'Boletos', end: false },
|
||||
{ to: '/area-do-cliente/conta', label: 'Minha conta', end: false },
|
||||
]
|
||||
|
||||
const dropdownItemCls = ({ isActive }: { isActive: boolean }) =>
|
||||
`block px-4 py-2 text-sm transition-colors ${isActive
|
||||
? 'bg-surface text-textPrimary font-medium'
|
||||
: 'text-textSecondary hover:text-textPrimary hover:bg-surface'
|
||||
}`
|
||||
const adminUserMenuItems: ClientNavItem[] = [
|
||||
{ to: '/admin/properties', label: 'Painel admin', end: false },
|
||||
]
|
||||
|
||||
const adminDropdownItemCls = ({ isActive }: { isActive: boolean }) =>
|
||||
`block px-4 py-2 text-sm transition-colors ${isActive
|
||||
? 'bg-admin/10 text-admin font-semibold'
|
||||
: 'text-admin/70 hover:text-admin hover:bg-admin/[0.06]'
|
||||
}`
|
||||
// ─── Helper: active state for query-param routes ──────────────────────────────
|
||||
|
||||
function useQueryNavActive(link: NavLinkDef): boolean {
|
||||
const location = useLocation()
|
||||
if (!link.matchPath) return false
|
||||
if (location.pathname !== link.matchPath) return false
|
||||
if (!link.matchQuery) return true
|
||||
const params = new URLSearchParams(location.search)
|
||||
return Object.entries(link.matchQuery).every(([k, v]) => params.get(k) === v)
|
||||
}
|
||||
|
||||
// ─── Subcomponents ────────────────────────────────────────────────────────────
|
||||
|
||||
function PublicNavItem({ link, onClick }: { link: NavLinkDef; onClick?: () => void }) {
|
||||
const isActive = useQueryNavActive(link)
|
||||
if (link.matchPath) {
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.pushState({}, '', link.href)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClick?.()
|
||||
}}
|
||||
className={`navbar-link ${isActive ? 'navbar-link--active' : ''}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<NavLink
|
||||
to={link.href}
|
||||
className={({ isActive: a }) => `navbar-link ${a ? 'navbar-link--active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function MobilePublicNavItem({ link, onClick }: { link: NavLinkDef; onClick: () => void }) {
|
||||
const isActive = useQueryNavActive(link)
|
||||
if (link.matchPath) {
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.pushState({}, '', link.href)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
onClick()
|
||||
}}
|
||||
className={`navbar-mobile-link ${isActive ? 'navbar-mobile-link--active' : ''}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<NavLink
|
||||
to={link.href}
|
||||
className={({ isActive: a }) => `navbar-mobile-link ${a ? 'navbar-mobile-link--active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoritesNavLink({ href }: { href: string }) {
|
||||
const { favoriteIds } = useFavorites()
|
||||
const count = favoriteIds.size
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="relative flex items-center gap-1 text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium"
|
||||
aria-label={count > 0 ? `Favoritos — ${count} imóvel${count > 1 ? 'is' : ''} salvo${count > 1 ? 's' : ''}` : 'Favoritos'}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
Favoritos
|
||||
{count > 0 && (
|
||||
<span aria-hidden="true" className="ml-0.5 min-w-[16px] h-4 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center px-1 leading-none">
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Navbar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Navbar() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [adminOpen, setAdminOpen] = useState(false)
|
||||
const [clientOpen, setClientOpen] = useState(false)
|
||||
// Single overlay controller — FR-011, FR-012, FR-013
|
||||
const [overlay, setOverlay] = useState<Overlay>('closed')
|
||||
|
||||
const { user, isAuthenticated, isLoading, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
const isAdmin = isAuthenticated && user && user.role === 'admin'
|
||||
// Derived variant
|
||||
const isAdmin = isAuthenticated && user?.role === 'admin'
|
||||
const isClient = isAuthenticated && !isAdmin
|
||||
|
||||
// Refs for click-outside detection
|
||||
const adminRef = useRef<HTMLDivElement>(null)
|
||||
const clientRef = useRef<HTMLDivElement>(null)
|
||||
const userRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Convenience toggles
|
||||
const open = useCallback((ctx: Overlay) => setOverlay(prev => prev === ctx ? 'closed' : ctx), [])
|
||||
const close = useCallback(() => setOverlay('closed'), [])
|
||||
|
||||
// Close on route change — FR-013
|
||||
useEffect(() => { close() }, [location.pathname, location.search, close])
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
function handleOutside(e: MouseEvent) {
|
||||
if (adminRef.current && !adminRef.current.contains(e.target as Node)) {
|
||||
setAdminOpen(false)
|
||||
if (overlay === 'closed') return
|
||||
if (overlay === 'mobile') return // mobile closes via button only
|
||||
if (overlay === 'admin' && adminRef.current && !adminRef.current.contains(e.target as Node)) {
|
||||
close()
|
||||
}
|
||||
if (clientRef.current && !clientRef.current.contains(e.target as Node)) {
|
||||
setClientOpen(false)
|
||||
if (overlay === 'user' && userRef.current && !userRef.current.contains(e.target as Node)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutside)
|
||||
return () => document.removeEventListener('mousedown', handleOutside)
|
||||
}, [])
|
||||
}, [overlay, close])
|
||||
|
||||
// Close on Escape key — FR-014
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && overlay !== 'closed') close()
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [overlay, close])
|
||||
|
||||
// Logout and close any open overlay
|
||||
const handleLogout = useCallback(() => {
|
||||
close()
|
||||
logout()
|
||||
}, [close, logout])
|
||||
|
||||
const menuOpen = overlay === 'mobile'
|
||||
const adminOpen = overlay === 'admin'
|
||||
const userOpen = overlay === 'user'
|
||||
const firstName = user?.name.split(' ')[0] ?? ''
|
||||
const userMenuItems = isAdmin ? adminUserMenuItems : clientNavItems
|
||||
const favoritesHref = isAuthenticated ? (isAdmin ? '/admin/favoritos' : '/area-do-cliente/favoritos') : '/favoritos'
|
||||
|
||||
return (
|
||||
<header
|
||||
|
|
@ -75,178 +219,224 @@ export default function Navbar() {
|
|||
>
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between"
|
||||
className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between gap-4"
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-textPrimary font-semibold text-base tracking-tight hover:opacity-80 transition-opacity"
|
||||
className="flex items-center gap-2 text-textPrimary font-semibold text-base tracking-tight hover:opacity-80 transition-opacity shrink-0"
|
||||
aria-label="ImobiliáriaHub — Página inicial"
|
||||
>
|
||||
<span className="w-6 h-6 bg-brand-indigo rounded flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
I
|
||||
</span>
|
||||
<span>ImobiliáriaHub</span>
|
||||
<span className="hidden sm:inline">ImobiliáriaHub</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<ul className="hidden md:flex items-center gap-6 list-none m-0 p-0">
|
||||
{navLinks.map((link) => (
|
||||
{/* Desktop nav — public links */}
|
||||
<ul className="hidden md:flex items-center gap-5 list-none m-0 p-0 flex-1 justify-center">
|
||||
{publicNavLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
{link.internal ? (
|
||||
<Link to={link.href} className="text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium">
|
||||
{link.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={link.href} className="text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium">
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
<PublicNavItem link={link} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Admin dropdown */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<div ref={adminRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setAdminOpen(o => !o); setClientOpen(false) }}
|
||||
className="flex items-center gap-1 text-sm text-admin hover:text-admin/80 font-semibold transition-colors"
|
||||
>
|
||||
Admin
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${adminOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{adminOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50">
|
||||
{adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={adminDropdownItemCls}
|
||||
onClick={() => setAdminOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* Client dropdown */}
|
||||
{isAuthenticated && user && !isAdmin && (
|
||||
<li>
|
||||
<div ref={clientRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setClientOpen(o => !o); setAdminOpen(false) }}
|
||||
className="flex items-center gap-1.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium"
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#5e6ad2] text-xs font-semibold text-white flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="max-w-[80px] truncate">{user.name.split(' ')[0]}</span>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${clientOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{clientOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50">
|
||||
{clientNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={dropdownItemCls}
|
||||
onClick={() => setClientOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="my-1 border-t border-borderSubtle" />
|
||||
<button
|
||||
onClick={() => { setClientOpen(false); logout() }}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-textTertiary hover:text-textPrimary hover:bg-surface transition-colors"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<li><ThemeToggle /></li>
|
||||
<li>
|
||||
<FavoritesNavLink href={favoritesHref} />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Desktop auth (apenas não-autenticado) */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
{/* Desktop: contextual actions + theme + CTA */}
|
||||
<div className="hidden md:flex items-center gap-2 shrink-0">
|
||||
|
||||
{/* Admin dropdown */}
|
||||
{isAdmin && (
|
||||
<div ref={adminRef} className="relative">
|
||||
<button
|
||||
id="admin-menu-btn"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={adminOpen}
|
||||
aria-controls="admin-dropdown"
|
||||
onClick={() => open('admin')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open('admin') } }}
|
||||
className="navbar-trigger navbar-trigger--admin"
|
||||
>
|
||||
Admin
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-150 ${adminOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{adminOpen && (
|
||||
<div
|
||||
id="admin-dropdown"
|
||||
role="menu"
|
||||
aria-labelledby="admin-menu-btn"
|
||||
className="absolute right-0 top-full mt-2 w-52 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50"
|
||||
>
|
||||
{adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
role="menuitem"
|
||||
className={({ isActive }) =>
|
||||
`navbar-dropdown-item ${isActive ? 'navbar-dropdown-item--admin-active' : 'navbar-dropdown-item--admin'}`
|
||||
}
|
||||
onClick={close}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<Link
|
||||
to="/cadastro-residencia"
|
||||
className="navbar-cta"
|
||||
>
|
||||
Anunciar imóvel
|
||||
</Link>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-8 w-16 animate-pulse rounded-lg bg-white/[0.06]" />
|
||||
<div aria-hidden="true" className="h-8 w-16 animate-pulse rounded-lg bg-white/[0.06]" />
|
||||
) : !isAuthenticated ? (
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-lg bg-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-white transition hover:bg-[#6872d8]"
|
||||
className="navbar-cta--primary"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
) : isAdmin ? (
|
||||
/* Admin: logout simples ao lado do dropdown */
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-textSecondary hover:text-textPrimary transition-colors duration-150 font-medium"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* User dropdown (sempre no fim da barra para usuários logados) */}
|
||||
{isAuthenticated && user && (
|
||||
<div ref={userRef} className="relative">
|
||||
<button
|
||||
id="user-menu-btn"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={userOpen}
|
||||
aria-controls="user-dropdown"
|
||||
aria-label={`Menu da conta de ${firstName}`}
|
||||
onClick={() => open('user')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open('user') } }}
|
||||
className="navbar-trigger"
|
||||
>
|
||||
<span aria-hidden="true" className="flex h-7 w-7 items-center justify-center rounded-full bg-[#5e6ad2] text-xs font-semibold text-white flex-shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="navbar-username">{firstName}</span>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-150 ${userOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{userOpen && (
|
||||
<div
|
||||
id="user-dropdown"
|
||||
role="menu"
|
||||
aria-labelledby="user-menu-btn"
|
||||
className="absolute right-0 top-full mt-2 w-52 rounded-xl border border-borderSubtle bg-panel shadow-xl py-1 z-50"
|
||||
>
|
||||
{userMenuItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
role="menuitem"
|
||||
className={({ isActive }) =>
|
||||
`navbar-dropdown-item ${isActive ? 'navbar-dropdown-item--active' : ''}`
|
||||
}
|
||||
onClick={close}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div role="separator" className="my-1 border-t border-borderSubtle" />
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={handleLogout}
|
||||
className="navbar-dropdown-item navbar-dropdown-item--logout w-full text-left"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<div className="md:hidden flex items-center gap-2">
|
||||
{/* Mobile: theme + hamburger */}
|
||||
<div className="md:hidden flex items-center gap-1 shrink-0">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
className="flex flex-col gap-1.5 p-2 rounded text-textSecondary hover:text-textPrimary transition-colors"
|
||||
aria-label={menuOpen ? 'Fechar menu' : 'Abrir menu'}
|
||||
className="navbar-hamburger"
|
||||
aria-label={menuOpen ? 'Fechar menu de navegação' : 'Abrir menu de navegação'}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMenuOpen(prev => !prev)}
|
||||
onClick={() => open('mobile')}
|
||||
>
|
||||
<span className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? 'translate-y-2 rotate-45' : ''}`} />
|
||||
<span className={`block w-5 h-0.5 bg-current transition-opacity duration-200 ${menuOpen ? 'opacity-0' : ''}`} />
|
||||
<span className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? '-translate-y-2 -rotate-45' : ''}`} />
|
||||
<span aria-hidden="true" className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? 'translate-y-2 rotate-45' : ''}`} />
|
||||
<span aria-hidden="true" className={`block w-5 h-0.5 bg-current transition-opacity duration-200 ${menuOpen ? 'opacity-0' : ''}`} />
|
||||
<span aria-hidden="true" className={`block w-5 h-0.5 bg-current transition-transform duration-200 ${menuOpen ? '-translate-y-2 -rotate-45' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{/* Mobile menu panel */}
|
||||
{menuOpen && (
|
||||
<div id="mobile-menu" className="md:hidden border-t border-borderSubtle bg-panel">
|
||||
<ul className="max-w-[1200px] mx-auto px-6 py-4 flex flex-col gap-1 list-none m-0 p-0">
|
||||
{navLinks.map((link) => (
|
||||
<div
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Menu de navegação"
|
||||
className="md:hidden border-t border-borderSubtle bg-panel"
|
||||
>
|
||||
<ul className="max-w-[1200px] mx-auto px-6 py-4 flex flex-col gap-0.5 list-none m-0 p-0">
|
||||
{/* Public links */}
|
||||
{publicNavLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
{link.internal ? (
|
||||
<Link to={link.href} className="block py-2.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium" onClick={() => setMenuOpen(false)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={link.href} className="block py-2.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium" onClick={() => setMenuOpen(false)}>
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
<MobilePublicNavItem link={link} onClick={close} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Mobile admin items */}
|
||||
<li>
|
||||
<Link
|
||||
to={favoritesHref}
|
||||
className="navbar-mobile-link"
|
||||
onClick={close}
|
||||
>
|
||||
Favoritos
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* CTA mobile */}
|
||||
<li className="pt-1">
|
||||
<Link
|
||||
to="/cadastro-residencia"
|
||||
className="block py-2.5 min-h-[44px] flex items-center text-sm font-semibold text-[#5e6ad2] hover:text-[#7170ff] transition-colors"
|
||||
onClick={close}
|
||||
>
|
||||
Anunciar imóvel
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* Admin section */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<li className="pt-2 pb-1">
|
||||
<li className="pt-3 pb-1">
|
||||
<span className="text-[10px] font-semibold text-textQuaternary uppercase tracking-widest px-0.5">Admin</span>
|
||||
</li>
|
||||
{adminNavItems.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} className={({ isActive }) => `block py-2 text-sm font-medium transition-colors ${isActive ? 'text-admin' : 'text-admin/60 hover:text-admin'}`} onClick={() => setMenuOpen(false)}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`navbar-mobile-link ${isActive ? 'text-admin font-medium' : 'text-admin/60 hover:text-admin'}`
|
||||
}
|
||||
onClick={close}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
|
|
@ -254,37 +444,71 @@ export default function Navbar() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile client items */}
|
||||
{isAuthenticated && user && !isAdmin && (
|
||||
{/* Client section */}
|
||||
{isClient && user && (
|
||||
<>
|
||||
<li className="pt-2 pb-1">
|
||||
<li className="pt-3 pb-1">
|
||||
<span className="text-[10px] font-semibold text-textQuaternary uppercase tracking-widest px-0.5">Minha Conta</span>
|
||||
</li>
|
||||
{clientNavItems.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} end={item.end} className={({ isActive }) => `block py-2 text-sm font-medium transition-colors ${isActive ? 'text-textPrimary' : 'text-textSecondary hover:text-textPrimary'}`} onClick={() => setMenuOpen(false)}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`navbar-mobile-link ${isActive ? 'navbar-mobile-link--active' : ''}`
|
||||
}
|
||||
onClick={close}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile auth */}
|
||||
{!isLoading && (
|
||||
isAuthenticated ? (
|
||||
<li>
|
||||
<button onClick={() => { setMenuOpen(false); logout() }} className="block py-2.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium w-full text-left">
|
||||
<li className="pt-1 border-t border-borderSubtle mt-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="navbar-mobile-link w-full text-left text-textTertiary hover:text-textPrimary"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<Link to="/login" className="block py-2.5 text-sm font-medium text-[#5e6ad2] hover:text-[#7170ff] transition-colors" onClick={() => setMenuOpen(false)}>
|
||||
Entrar
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User section (admin) */}
|
||||
{isAdmin && user && (
|
||||
<>
|
||||
<li className="pt-3 pb-1">
|
||||
<span className="text-[10px] font-semibold text-textQuaternary uppercase tracking-widest px-0.5">Conta</span>
|
||||
</li>
|
||||
)
|
||||
<li>
|
||||
<span className="navbar-mobile-link text-textSecondary/90">{user.name}</span>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Auth actions */}
|
||||
{!isLoading && !isAuthenticated && (
|
||||
<li className="pt-1">
|
||||
<Link
|
||||
to="/login"
|
||||
className="block py-2.5 min-h-[44px] flex items-center text-sm font-semibold text-[#5e6ad2] hover:text-[#7170ff] transition-colors"
|
||||
onClick={close}
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{!isLoading && isAuthenticated && isAdmin && (
|
||||
<li className="pt-1 border-t border-borderSubtle mt-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="navbar-mobile-link w-full text-left text-textTertiary hover:text-textPrimary"
|
||||
>
|
||||
Sair
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import HeartButton from '../components/HeartButton';
|
||||
import ContactModal from './ContactModal';
|
||||
import { useComparison } from '../contexts/ComparisonContext';
|
||||
import type { Property } from '../types/property';
|
||||
import ContactModal from './ContactModal';
|
||||
|
||||
interface PropertyCardProps {
|
||||
property: Property
|
||||
|
|
@ -140,7 +140,10 @@ export default function PropertyCard({ property }: PropertyCardProps) {
|
|||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<HeartButton propertyId={property.id} />
|
||||
<HeartButton
|
||||
propertyId={property.id}
|
||||
snapshot={{ id: property.id, slug: property.slug, title: property.title, price: property.price, type: property.type, photo: property.photos?.[0]?.url ?? null, city: property.city?.name ?? null, bedrooms: property.bedrooms, area_m2: property.area_m2 }}
|
||||
/>
|
||||
</div>
|
||||
{/* Badge sobreposto à foto */}
|
||||
<div className="absolute bottom-2 left-2">
|
||||
|
|
|
|||
|
|
@ -61,19 +61,23 @@ export default function PropertyGridCard({ property }: { property: Property }) {
|
|||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1 pointer-events-none">
|
||||
{property.is_featured && (
|
||||
{/* Featured badge */}
|
||||
{property.is_featured && (
|
||||
<div className="absolute top-2 left-2 z-10 pointer-events-none">
|
||||
<span className="inline-flex items-center gap-1 rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm bg-amber-500/90 text-white">
|
||||
⭐ Destaque
|
||||
</span>
|
||||
)}
|
||||
{showNew && (
|
||||
<span className="inline-flex items-center rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm bg-emerald-500/90 text-white">
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo — corner ribbon */}
|
||||
{showNew && (
|
||||
<div className="absolute top-0 right-0 w-16 h-16 overflow-hidden z-10 pointer-events-none">
|
||||
<div className="absolute rotate-45 bg-emerald-500/90 text-white text-[10px] font-bold tracking-wide text-center shadow-sm" style={{ width: '80px', top: '10px', right: '-14px' }}>
|
||||
Novo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Listing type */}
|
||||
<div className="absolute top-2 right-2 z-10 pointer-events-none">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import ContactModal from './ContactModal'
|
||||
import { useComparison } from '../contexts/ComparisonContext'
|
||||
import type { Property } from '../types/property'
|
||||
import ContactModal from './ContactModal'
|
||||
import HeartButton from './HeartButton'
|
||||
|
||||
// ── Badge helpers ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -79,6 +79,8 @@ function SlideImage({ src, alt }: { src: string; alt: string }) {
|
|||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={`w-full h-full object-cover transition-opacity duration-500 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
draggable={false}
|
||||
|
|
@ -126,18 +128,22 @@ function PhotoCarousel({ photos, title, isNew: showNew, isFeatured }: {
|
|||
))}
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="absolute top-2 left-2 z-20 flex flex-col gap-1 pointer-events-none">
|
||||
{isFeatured && (
|
||||
{isFeatured && (
|
||||
<div className="absolute top-2 left-2 z-20 pointer-events-none">
|
||||
<span className="inline-flex items-center gap-1 rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm shadow bg-amber-500/90 text-white">
|
||||
⭐ Destaque
|
||||
</span>
|
||||
)}
|
||||
{showNew && (
|
||||
<span className="inline-flex items-center rounded-full text-[11px] font-semibold px-2 py-0.5 backdrop-blur-sm shadow bg-emerald-500/90 text-white">
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Novo — corner ribbon */}
|
||||
{showNew && (
|
||||
<div className="absolute top-0 right-0 w-16 h-16 overflow-hidden z-20 pointer-events-none">
|
||||
<div className="absolute rotate-45 bg-emerald-500/90 text-white text-[10px] font-bold tracking-wide text-center shadow-sm" style={{ width: '80px', top: '8px', right: '-27px' }}>
|
||||
Novo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev / Next — visible on mobile, hover-only on desktop */}
|
||||
{slides.length > 1 && (
|
||||
|
|
@ -214,18 +220,12 @@ export default function PropertyRowCard({ property }: { property: Property }) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subtype badge */}
|
||||
{property.subtype && (
|
||||
<div className="absolute bottom-3 left-3 z-20 pointer-events-none">
|
||||
<span className="inline-flex items-center rounded-full text-[11px] font-medium px-2 py-0.5 backdrop-blur-sm shadow bg-black/50 text-white/90 border border-white/20">
|
||||
{property.subtype.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heart */}
|
||||
<div className="absolute top-3 right-3 z-20">
|
||||
<HeartButton propertyId={property.id} />
|
||||
<HeartButton
|
||||
propertyId={property.id}
|
||||
snapshot={{ id: property.id, slug: property.slug, title: property.title, price: property.price, type: property.type, photo: property.photos?.[0]?.url ?? null, city: property.city?.name ?? null, bedrooms: property.bedrooms, area_m2: property.area_m2 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -239,16 +239,23 @@ export default function PropertyRowCard({ property }: { property: Property }) {
|
|||
|
||||
{/* ── Info (right) ─────────────────────────────────────────────── */}
|
||||
<div className="relative z-10 flex flex-col flex-1 min-w-0 p-5 gap-2 cursor-pointer" onClick={() => navigate(`/imoveis/${property.slug}`)}>
|
||||
{/* Title + code */}
|
||||
{/* Title + code + subtype */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-textPrimary leading-snug line-clamp-2 flex-1">
|
||||
{property.title}
|
||||
</h3>
|
||||
{property.code && (
|
||||
<span className="text-[11px] text-textTertiary bg-surface border border-borderSubtle rounded px-1.5 py-0.5 shrink-0 font-mono">
|
||||
#{property.code}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-1 shrink-0 flex-wrap justify-end">
|
||||
{property.subtype && (
|
||||
<span className="text-[11px] text-textTertiary bg-surface border border-borderSubtle rounded px-1.5 py-0.5">
|
||||
{property.subtype.name}
|
||||
</span>
|
||||
)}
|
||||
{property.code && (
|
||||
<span className="text-[11px] text-textTertiary bg-surface border border-borderSubtle rounded px-1.5 py-0.5 font-mono">
|
||||
#{property.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
@ -270,14 +277,14 @@ export default function PropertyRowCard({ property }: { property: Property }) {
|
|||
<span className="text-xs text-textTertiary font-normal ml-1">/mês</span>
|
||||
)}
|
||||
</p>
|
||||
{(property.condo_fee || property.iptu_anual) && (
|
||||
{(property.condo_fee != null || property.iptu_anual != null) && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{property.condo_fee && parseFloat(property.condo_fee) > 0 && (
|
||||
{property.condo_fee != null && (
|
||||
<span className="text-[11px] text-textTertiary">
|
||||
Cond. {formatPrice(property.condo_fee)}/mês
|
||||
</span>
|
||||
)}
|
||||
{property.iptu_anual && parseFloat(property.iptu_anual) > 0 && (
|
||||
{property.iptu_anual != null && (
|
||||
<span className="text-[11px] text-textTertiary">
|
||||
IPTU {formatPrice(String(parseFloat(property.iptu_anual) / 12))}/mês
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface AuthContextValue {
|
|||
login: (data: LoginCredentials) => Promise<void>
|
||||
register: (data: RegisterCredentials) => Promise<void>
|
||||
logout: () => void
|
||||
updateUser: (partial: Partial<User>) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
|
@ -55,6 +56,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
window.location.href = '/login'
|
||||
}, [])
|
||||
|
||||
const updateUser = useCallback((partial: Partial<User>) => {
|
||||
setUser(prev => (prev ? { ...prev, ...partial } : prev))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
|
|
@ -65,6 +70,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,32 @@ import React, { createContext, useCallback, useContext, useEffect, useState } fr
|
|||
import { addFavorite, getFavorites, removeFavorite } from '../services/clientArea';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export interface LocalFavoriteEntry {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
price: string;
|
||||
type: 'venda' | 'aluguel';
|
||||
photo: string | null;
|
||||
city: string | null;
|
||||
bedrooms: number;
|
||||
area_m2: number;
|
||||
}
|
||||
|
||||
const LOCAL_KEY = 'local_favorites';
|
||||
|
||||
function readLocal(): LocalFavoriteEntry[] {
|
||||
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
|
||||
function writeLocal(entries: LocalFavoriteEntry[]) {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
interface FavoritesContextValue {
|
||||
favoriteIds: Set<string>;
|
||||
toggle: (propertyId: string) => Promise<void>;
|
||||
localEntries: LocalFavoriteEntry[];
|
||||
toggle: (propertyId: string, snapshot?: LocalFavoriteEntry) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -13,28 +36,72 @@ const FavoritesContext = createContext<FavoritesContextValue | null>(null);
|
|||
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [localEntries, setLocalEntries] = useState<LocalFavoriteEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setFavoriteIds(new Set());
|
||||
const entries = readLocal();
|
||||
setFavoriteIds(new Set(entries.map(e => e.id)));
|
||||
setLocalEntries(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
getFavorites()
|
||||
.then(saved => {
|
||||
// saved is SavedProperty[] — need property_id values
|
||||
const ids = saved
|
||||
.then(async saved => {
|
||||
const serverIds = new Set(
|
||||
saved.filter((s: any) => s.property_id).map((s: any) => s.property_id as string)
|
||||
);
|
||||
|
||||
// Merge local favorites → server
|
||||
const local = readLocal();
|
||||
const toSync = local.filter(e => !serverIds.has(e.id));
|
||||
|
||||
if (toSync.length > 0) {
|
||||
const results = await Promise.allSettled(toSync.map(e => addFavorite(e.id)));
|
||||
// Only remove from localStorage the IDs that were successfully synced
|
||||
const syncedIds = new Set(
|
||||
toSync.filter((_, i) => results[i].status === 'fulfilled').map(e => e.id)
|
||||
);
|
||||
const remaining = local.filter(e => !syncedIds.has(e.id));
|
||||
writeLocal(remaining);
|
||||
setLocalEntries(remaining);
|
||||
}
|
||||
|
||||
// Refresh from server
|
||||
const fresh = await getFavorites();
|
||||
const ids = fresh
|
||||
.filter((s: any) => s.property_id)
|
||||
.map((s: any) => s.property_id as string);
|
||||
setFavoriteIds(new Set(ids));
|
||||
})
|
||||
.catch(() => setFavoriteIds(new Set()))
|
||||
.catch(() => {
|
||||
// Don't wipe favoriteIds — just keep whatever state we have
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const toggle = useCallback(async (propertyId: string) => {
|
||||
if (!isAuthenticated) return;
|
||||
const toggle = useCallback(async (propertyId: string, snapshot?: LocalFavoriteEntry) => {
|
||||
if (!isAuthenticated) {
|
||||
const entries = readLocal();
|
||||
const idx = entries.findIndex(e => e.id === propertyId);
|
||||
let next: LocalFavoriteEntry[];
|
||||
if (idx >= 0) {
|
||||
next = entries.filter(e => e.id !== propertyId);
|
||||
} else {
|
||||
const entry: LocalFavoriteEntry = snapshot ?? {
|
||||
id: propertyId, slug: '', title: 'Imóvel', price: '',
|
||||
type: 'venda', photo: null, city: null, bedrooms: 0, area_m2: 0,
|
||||
};
|
||||
next = [...entries, entry];
|
||||
}
|
||||
writeLocal(next);
|
||||
setLocalEntries(next);
|
||||
setFavoriteIds(new Set(next.map(e => e.id)));
|
||||
return;
|
||||
}
|
||||
|
||||
const wasIn = favoriteIds.has(propertyId);
|
||||
// Optimistic update
|
||||
setFavoriteIds(prev => {
|
||||
|
|
@ -58,7 +125,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
|||
}, [isAuthenticated, favoriteIds]);
|
||||
|
||||
return (
|
||||
<FavoritesContext.Provider value={{ favoriteIds, toggle, isLoading }}>
|
||||
<FavoritesContext.Provider value={{ favoriteIds, localEntries, toggle, isLoading }}>
|
||||
{children}
|
||||
</FavoritesContext.Provider>
|
||||
);
|
||||
|
|
|
|||
22
frontend/src/hooks/useInView.ts
Normal file
22
frontend/src/hooks/useInView.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useInView(options?: IntersectionObserverInit) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, options)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return { ref, inView }
|
||||
}
|
||||
|
|
@ -118,6 +118,92 @@
|
|||
}
|
||||
|
||||
@layer components {
|
||||
/* ─── Navbar shared utilities ─────────────────────────────────────────── */
|
||||
|
||||
/* Desktop nav link */
|
||||
.navbar-link {
|
||||
@apply text-sm font-medium transition-colors duration-150 text-textSecondary hover:text-textPrimary;
|
||||
}
|
||||
|
||||
.navbar-link--active {
|
||||
@apply text-textPrimary;
|
||||
}
|
||||
|
||||
/* Desktop trigger button (client / admin dropdown) */
|
||||
.navbar-trigger {
|
||||
@apply flex items-center gap-1.5 text-sm text-textSecondary hover:text-textPrimary transition-colors font-medium min-h-[44px] px-1 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60;
|
||||
}
|
||||
|
||||
.navbar-trigger--admin {
|
||||
@apply font-semibold;
|
||||
color: var(--color-admin);
|
||||
}
|
||||
|
||||
.navbar-trigger--admin:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Truncated username in client trigger */
|
||||
.navbar-username {
|
||||
@apply max-w-[96px] truncate leading-none;
|
||||
}
|
||||
|
||||
/* Desktop dropdown item */
|
||||
.navbar-dropdown-item {
|
||||
@apply block px-4 py-2 text-sm transition-colors text-textSecondary hover:text-textPrimary hover:bg-surface;
|
||||
}
|
||||
|
||||
.navbar-dropdown-item--active {
|
||||
@apply bg-surface text-textPrimary font-medium;
|
||||
}
|
||||
|
||||
.navbar-dropdown-item--admin {
|
||||
color: color-mix(in srgb, var(--color-admin) 70%, transparent);
|
||||
}
|
||||
|
||||
.navbar-dropdown-item--admin:hover {
|
||||
color: var(--color-admin);
|
||||
background-color: color-mix(in srgb, var(--color-admin) 6%, transparent);
|
||||
}
|
||||
|
||||
.navbar-dropdown-item--admin-active {
|
||||
@apply font-semibold;
|
||||
color: var(--color-admin);
|
||||
background-color: color-mix(in srgb, var(--color-admin) 10%, transparent);
|
||||
}
|
||||
|
||||
.navbar-dropdown-item--logout {
|
||||
@apply text-textTertiary hover:text-textPrimary hover:bg-surface;
|
||||
}
|
||||
|
||||
/* CTA buttons */
|
||||
.navbar-cta {
|
||||
@apply rounded-lg border border-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-[#5e6ad2] transition hover:bg-[#5e6ad2] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5e6ad2]/60;
|
||||
}
|
||||
|
||||
.navbar-cta--primary {
|
||||
@apply rounded-lg bg-[#5e6ad2] px-4 py-1.5 text-sm font-medium text-white transition hover:bg-[#6872d8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5e6ad2]/60;
|
||||
}
|
||||
|
||||
/* Mobile menu link */
|
||||
.navbar-mobile-link {
|
||||
@apply block py-2.5 min-h-[44px] flex items-center text-sm font-medium transition-colors text-textSecondary hover:text-textPrimary;
|
||||
}
|
||||
|
||||
.navbar-mobile-link--active {
|
||||
@apply text-textPrimary;
|
||||
}
|
||||
|
||||
/* Hamburger button — 44×44 touch target */
|
||||
.navbar-hamburger {
|
||||
@apply flex flex-col gap-1.5 p-2 rounded text-textSecondary hover:text-textPrimary transition-colors min-h-[44px] min-w-[44px] items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60;
|
||||
}
|
||||
|
||||
/* Generic touch target helper */
|
||||
.navbar-touch-target {
|
||||
@apply min-h-[44px] min-w-[44px] flex items-center justify-center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-5 py-2.5 bg-brand hover:bg-accentHover text-white font-medium text-sm rounded transition-colors duration-200;
|
||||
font-feature-settings: "cv01", "ss03";
|
||||
|
|
@ -140,9 +226,7 @@
|
|||
|
||||
/* Inputs de formulário theme-aware */
|
||||
.form-input {
|
||||
@apply w-full rounded-lg border border-borderPrimary bg-surface px-3 py-2 text-sm text-textPrimary placeholder:text-textQuaternary
|
||||
focus:border-accent/60 focus:outline-none focus:ring-1 focus:ring-accent/30
|
||||
transition-colors;
|
||||
@apply w-full rounded-lg border border-borderPrimary bg-surface px-3 py-2 text-sm text-textPrimary placeholder:text-textQuaternary focus:border-accent/60 focus:outline-none focus:ring-1 focus:ring-accent/30 transition-colors;
|
||||
}
|
||||
|
||||
/* Labels de formulário theme-aware */
|
||||
|
|
@ -157,12 +241,14 @@
|
|||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Stagger entry animation for property cards */
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
|
@ -179,3 +265,8 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeDown {
|
||||
0%, 100% { opacity: 0; transform: translateY(-4px); }
|
||||
50% { opacity: 1; transform: translateY(4px); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +1,13 @@
|
|||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
|
||||
|
||||
const navItems = [
|
||||
{ to: '/area-do-cliente', label: 'Painel', end: true, icon: '⊞' },
|
||||
{ to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false, icon: '♡' },
|
||||
{ to: '/area-do-cliente/comparar', label: 'Comparar', end: false, icon: '⇄' },
|
||||
{ to: '/area-do-cliente/visitas', label: 'Visitas', end: false, icon: '📅' },
|
||||
{ to: '/area-do-cliente/boletos', label: 'Boletos', end: false, icon: '📄' },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ to: '/admin', label: 'Admin', end: false, icon: '⚙️' },
|
||||
];
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Navbar from '../components/Navbar';
|
||||
|
||||
export default function ClientLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
// Adiciona pt-14 para compensar o header fixo (Navbar)
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas pt-14">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden lg:flex w-56 flex-col border-r border-borderSubtle bg-panel px-3 py-6">
|
||||
{/* Theme toggle */}
|
||||
<div className="flex items-center justify-between mb-6 px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand text-sm font-medium text-white shrink-0">
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-textPrimary">{user?.name}</p>
|
||||
<p className="truncate text-xs text-textSecondary">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="min-h-screen bg-canvas pt-14">
|
||||
<Navbar />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-1 flex-col gap-0.5">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition ${isActive
|
||||
? 'bg-surface text-textPrimary font-medium'
|
||||
: 'text-textSecondary hover:text-textPrimary hover:bg-surface'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{isAdmin && adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition ${isActive
|
||||
? 'bg-[#f5c518] text-black font-semibold'
|
||||
: 'text-[#f5c518] hover:text-black hover:bg-[#ffe066]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-textTertiary hover:text-textPrimary hover:bg-surface transition mt-4"
|
||||
>
|
||||
<span>→</span>Sair
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 overflow-auto">
|
||||
{/* Mobile nav */}
|
||||
<div className="lg:hidden border-b border-borderSubtle bg-panel overflow-x-auto flex items-center justify-between px-2 py-2">
|
||||
<div className="flex gap-0.5">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`shrink-0 rounded-lg px-3 py-1.5 text-xs transition ${isActive
|
||||
? 'bg-surface text-textPrimary font-medium'
|
||||
: 'text-textSecondary hover:text-textPrimary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{isAdmin && adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold transition ${isActive
|
||||
? 'bg-[#f5c518] text-black'
|
||||
: 'text-[#f5c518] hover:text-black hover:bg-[#ffe066]'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<main className="mx-auto w-full max-w-7xl min-w-0 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
432
frontend/src/pages/CadastroResidenciaPage.tsx
Normal file
432
frontend/src/pages/CadastroResidenciaPage.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Footer from '../components/Footer'
|
||||
import Navbar from '../components/Navbar'
|
||||
import { submitGeneralContact } from '../services/properties'
|
||||
|
||||
const TIPOS_IMOVEL = [
|
||||
'Apartamento',
|
||||
'Casa',
|
||||
'Casa de condomínio',
|
||||
'Cobertura',
|
||||
'Flat / Studio',
|
||||
'Terreno',
|
||||
'Sala comercial',
|
||||
'Galpão',
|
||||
'Outro',
|
||||
]
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
finalidade: string
|
||||
tipo_imovel: string
|
||||
valor: string
|
||||
valor_condominio: string
|
||||
area_interna: string
|
||||
quartos: string
|
||||
suites: string
|
||||
banheiros: string
|
||||
vagas: string
|
||||
aceita_permuta: boolean
|
||||
aceita_financiamento: boolean
|
||||
ocupado: boolean
|
||||
cep: string
|
||||
logradouro: string
|
||||
numero: string
|
||||
bairro: string
|
||||
cidade: string
|
||||
complemento: string
|
||||
message: string
|
||||
privacy: boolean
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
name: '', phone: '', email: '',
|
||||
finalidade: '', tipo_imovel: '', valor: '', valor_condominio: '',
|
||||
area_interna: '', quartos: '', suites: '', banheiros: '', vagas: '',
|
||||
aceita_permuta: false, aceita_financiamento: false, ocupado: false,
|
||||
cep: '', logradouro: '', numero: '', bairro: '', cidade: '', complemento: '',
|
||||
message: '', privacy: false,
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40'
|
||||
const labelCls = 'block text-xs font-medium text-textSecondary mb-1'
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
num: '01',
|
||||
title: 'Preencha o formulário',
|
||||
desc: 'Informe os dados do seu imóvel e seus dados de contato no formulário abaixo.',
|
||||
},
|
||||
{
|
||||
num: '02',
|
||||
title: 'Captador especialista',
|
||||
desc: 'Suas informações serão direcionadas para um de nossos corretores especializados.',
|
||||
},
|
||||
{
|
||||
num: '03',
|
||||
title: 'Avaliação e anúncio',
|
||||
desc: 'Nosso corretor agendará uma visita para avaliar o imóvel e iniciar o processo de anúncio.',
|
||||
},
|
||||
]
|
||||
|
||||
const BENEFICIOS = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Atendimento qualificado',
|
||||
desc: 'Corretores experientes e dedicados ao seu imóvel.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Maior visibilidade',
|
||||
desc: 'Anúncios nos principais portais do mercado imobiliário.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Melhor negociação',
|
||||
desc: 'Agilidade no processo e suporte completo até o fechamento.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function CadastroResidenciaPage() {
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) {
|
||||
const { name, value, type } = e.target
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!form.privacy) {
|
||||
setError('Você precisa aceitar a Política de Privacidade para continuar.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const parts: string[] = []
|
||||
if (form.finalidade) parts.push(`Finalidade: ${form.finalidade}`)
|
||||
if (form.tipo_imovel) parts.push(`Tipo: ${form.tipo_imovel}`)
|
||||
if (form.valor) parts.push(`Valor: R$ ${form.valor}`)
|
||||
if (form.valor_condominio) parts.push(`Condomínio: R$ ${form.valor_condominio}`)
|
||||
if (form.area_interna) parts.push(`Área interna: ${form.area_interna} m²`)
|
||||
if (form.quartos) parts.push(`Quartos: ${form.quartos}`)
|
||||
if (form.suites) parts.push(`Suítes: ${form.suites}`)
|
||||
if (form.banheiros) parts.push(`Banheiros: ${form.banheiros}`)
|
||||
if (form.vagas) parts.push(`Vagas: ${form.vagas}`)
|
||||
const flags = [
|
||||
form.aceita_permuta && 'Aceita permuta',
|
||||
form.aceita_financiamento && 'Aceita financiamento',
|
||||
form.ocupado && 'Imóvel ocupado',
|
||||
].filter(Boolean)
|
||||
if (flags.length) parts.push(flags.join(' | '))
|
||||
const endereco = [form.logradouro, form.numero, form.bairro, form.cidade, form.cep, form.complemento]
|
||||
.filter(Boolean).join(', ')
|
||||
if (endereco) parts.push(`Endereço: ${endereco}`)
|
||||
if (form.message) parts.push(form.message)
|
||||
|
||||
await submitGeneralContact({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
message: parts.join('\n') || 'Cadastro de imóvel.',
|
||||
source: 'cadastro_residencia',
|
||||
source_detail: form.tipo_imovel || undefined,
|
||||
})
|
||||
setSuccess(true)
|
||||
setForm(INITIAL)
|
||||
} catch {
|
||||
setError('Não foi possível enviar seu cadastro. Tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main id="main-content" className="min-h-screen bg-canvas">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-surface border-b border-borderSubtle">
|
||||
<div className="max-w-[1080px] mx-auto px-6 pt-24 pb-16">
|
||||
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
|
||||
Quero anunciar
|
||||
</p>
|
||||
<h1
|
||||
className="text-3xl md:text-5xl font-semibold text-textPrimary tracking-tight max-w-[680px] leading-tight"
|
||||
style={{ fontFeatureSettings: '"cv01","ss03"' }}
|
||||
>
|
||||
Ajudamos você a vender ou alugar seu imóvel com rapidez
|
||||
</h1>
|
||||
<p className="mt-5 text-textSecondary text-base md:text-lg leading-relaxed max-w-[560px]">
|
||||
Anuncie conosco e tenha acesso aos melhores portais do mercado imobiliário,
|
||||
com atendimento especializado do início ao fechamento.
|
||||
</p>
|
||||
<div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{STEPS.map((s) => (
|
||||
<div key={s.num} className="flex gap-4">
|
||||
<span className="text-3xl font-bold text-[#5e6ad2]/20 leading-none shrink-0 select-none">
|
||||
{s.num}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-textPrimary">{s.title}</p>
|
||||
<p className="text-xs text-textTertiary mt-1 leading-relaxed">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefícios */}
|
||||
<section className="max-w-[1080px] mx-auto px-6 py-14">
|
||||
<h2 className="text-lg font-semibold text-textPrimary mb-8 text-center">
|
||||
Benefícios que oferecemos para você
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{BENEFICIOS.map((b) => (
|
||||
<div
|
||||
key={b.title}
|
||||
className="bg-panel border border-borderSubtle rounded-xl p-6 flex flex-col items-center text-center gap-3"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-[#5e6ad2]/10 flex items-center justify-center text-[#5e6ad2]">
|
||||
{b.icon}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-textPrimary">{b.title}</p>
|
||||
<p className="text-xs text-textTertiary leading-relaxed">{b.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Formulário */}
|
||||
<section className="max-w-[800px] mx-auto px-6 pb-24">
|
||||
<p className="text-center text-textSecondary text-sm mb-8">
|
||||
Preencha o formulário abaixo e anuncie seu imóvel conosco!
|
||||
</p>
|
||||
<div className="bg-panel border border-borderSubtle rounded-2xl p-8">
|
||||
{success ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-14 h-14 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-7 h-7 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-textPrimary mb-2">Cadastro enviado!</h3>
|
||||
<p className="text-textSecondary text-sm mb-6 max-w-[360px] mx-auto">
|
||||
Recebemos suas informações. Em breve um corretor especialista entrará em contato.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="text-sm text-[#5e6ad2] hover:underline"
|
||||
>
|
||||
Cadastrar outro imóvel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-8" noValidate>
|
||||
|
||||
{/* Dados pessoais */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-4 pb-2 border-b border-borderSubtle">
|
||||
Dados Pessoais
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Nome <span className="text-red-400">*</span></label>
|
||||
<input type="text" name="name" value={form.name} onChange={handleChange} required placeholder="Seu nome completo" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Telefone <span className="text-red-400">*</span></label>
|
||||
<input type="tel" name="phone" value={form.phone} onChange={handleChange} required placeholder="(11) 99999-0000" className={inputCls} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelCls}>E-mail <span className="text-red-400">*</span></label>
|
||||
<input type="email" name="email" value={form.email} onChange={handleChange} required placeholder="seu@email.com" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dados do imóvel */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-4 pb-2 border-b border-borderSubtle">
|
||||
Dados do Imóvel
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Finalidade <span className="text-red-400">*</span></label>
|
||||
<select name="finalidade" value={form.finalidade} onChange={handleChange} required className={inputCls}>
|
||||
<option value="">Selecione…</option>
|
||||
<option>Venda</option>
|
||||
<option>Locação</option>
|
||||
<option>Venda e Locação</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Tipo do imóvel</label>
|
||||
<select name="tipo_imovel" value={form.tipo_imovel} onChange={handleChange} className={inputCls}>
|
||||
<option value="">Selecione…</option>
|
||||
{TIPOS_IMOVEL.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Valor (R$)</label>
|
||||
<input type="text" name="valor" value={form.valor} onChange={handleChange} placeholder="Ex: 450.000" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Valor do condomínio (R$)</label>
|
||||
<input type="text" name="valor_condominio" value={form.valor_condominio} onChange={handleChange} placeholder="Ex: 600" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Área interna (m²)</label>
|
||||
<input type="number" name="area_interna" value={form.area_interna} onChange={handleChange} min={1} placeholder="85" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Quartos</label>
|
||||
<input type="number" name="quartos" value={form.quartos} onChange={handleChange} min={0} placeholder="3" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Suítes</label>
|
||||
<input type="number" name="suites" value={form.suites} onChange={handleChange} min={0} placeholder="1" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Vagas</label>
|
||||
<input type="number" name="vagas" value={form.vagas} onChange={handleChange} min={0} placeholder="2" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-5 pt-1">
|
||||
{[
|
||||
{ name: 'aceita_permuta', label: 'Aceita permuta' },
|
||||
{ name: 'aceita_financiamento', label: 'Aceita financiamento' },
|
||||
{ name: 'ocupado', label: 'Imóvel ocupado' },
|
||||
].map(({ name, label }) => (
|
||||
<label key={name} className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={form[name as keyof FormState] as boolean}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 accent-[#5e6ad2] rounded"
|
||||
/>
|
||||
<span className="text-sm text-textSecondary">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endereço */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-textTertiary uppercase tracking-widest mb-4 pb-2 border-b border-borderSubtle">
|
||||
Endereço do Imóvel
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>CEP</label>
|
||||
<input type="text" name="cep" value={form.cep} onChange={handleChange} placeholder="00000-000" className={inputCls} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelCls}>Logradouro</label>
|
||||
<input type="text" name="logradouro" value={form.logradouro} onChange={handleChange} placeholder="Rua, Avenida…" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Número</label>
|
||||
<input type="text" name="numero" value={form.numero} onChange={handleChange} placeholder="123" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Bairro</label>
|
||||
<input type="text" name="bairro" value={form.bairro} onChange={handleChange} placeholder="Centro" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Cidade</label>
|
||||
<input type="text" name="cidade" value={form.cidade} onChange={handleChange} placeholder="São Paulo" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Complemento</label>
|
||||
<input type="text" name="complemento" value={form.complemento} onChange={handleChange} placeholder="Ap. 42, Bloco B…" className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Observações */}
|
||||
<div>
|
||||
<label className={labelCls}>Informações adicionais</label>
|
||||
<textarea
|
||||
name="message"
|
||||
value={form.message}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
placeholder="Descreva características relevantes do imóvel…"
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Privacidade + envio */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="privacy"
|
||||
checked={form.privacy}
|
||||
onChange={handleChange}
|
||||
className="mt-0.5 w-4 h-4 accent-[#5e6ad2] shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-textTertiary leading-relaxed">
|
||||
Ao informar meus dados, concordo com a{' '}
|
||||
<Link to="/politica-de-privacidade" className="text-[#5e6ad2] hover:underline">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-brand hover:bg-accentHover text-white font-semibold text-sm rounded-lg py-3 transition-colors disabled:opacity-60 uppercase tracking-wide"
|
||||
>
|
||||
{loading ? 'Enviando…' : 'Anunciar imóvel'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
260
frontend/src/pages/ContactPage.tsx
Normal file
260
frontend/src/pages/ContactPage.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import Footer from '../components/Footer'
|
||||
import Navbar from '../components/Navbar'
|
||||
import { getContactConfig, type ContactConfig } from '../services/contactConfig'
|
||||
import { submitGeneralContact } from '../services/properties'
|
||||
|
||||
const ASSUNTOS = [
|
||||
'Quero comprar um imóvel',
|
||||
'Quero alugar um imóvel',
|
||||
'Quero vender meu imóvel',
|
||||
'Tenho dúvidas gerais',
|
||||
'Outro assunto',
|
||||
]
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
}
|
||||
|
||||
export default function ContactPage() {
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<ContactConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getContactConfig().then(setInfo).catch(() => {/* silently keep null */ })
|
||||
}, [])
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await submitGeneralContact({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
message: form.subject ? `[${form.subject}] ${form.message}` : form.message,
|
||||
source: 'contato',
|
||||
source_detail: form.subject || undefined,
|
||||
})
|
||||
setSuccess(true)
|
||||
setForm(INITIAL)
|
||||
} catch {
|
||||
setError('Não foi possível enviar sua mensagem. Tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main id="main-content" className="min-h-screen bg-canvas">
|
||||
<section className="max-w-[1080px] mx-auto px-6 pt-20 pb-16">
|
||||
<p className="text-[#5e6ad2] text-xs font-medium tracking-widest uppercase mb-3">
|
||||
Fale conosco
|
||||
</p>
|
||||
<h1
|
||||
className="text-3xl md:text-5xl font-semibold text-textPrimary tracking-tight max-w-[640px] leading-tight"
|
||||
style={{ fontFeatureSettings: '"cv01","ss03"' }}
|
||||
>
|
||||
Entre em contato
|
||||
</h1>
|
||||
<p className="mt-4 text-textSecondary text-base md:text-lg leading-relaxed max-w-[560px]">
|
||||
Nossa equipe está pronta para ajudá-lo. Preencha o formulário e retornaremos em até 24 horas.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="max-w-[1080px] mx-auto px-6 pb-24 grid md:grid-cols-2 gap-12">
|
||||
{/* ── Info ─────────────────────────────────────────── */}
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-textPrimary mb-4">Nosso escritório</h2>
|
||||
<ul className="space-y-4 text-textSecondary text-sm">
|
||||
{(info?.address_street || info?.address_neighborhood_city || info?.address_zip) && (
|
||||
<li className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 mt-0.5 text-[#5e6ad2] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
{info?.address_street && <>{info.address_street}<br /></>}
|
||||
{info?.address_neighborhood_city && <>{info.address_neighborhood_city}<br /></>}
|
||||
{info?.address_zip}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{info?.phone && (
|
||||
<li className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-[#5e6ad2] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span>{info.phone}</span>
|
||||
</li>
|
||||
)}
|
||||
{info?.email && (
|
||||
<li className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-[#5e6ad2] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{info.email}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{info?.business_hours && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-textPrimary mb-2">Horário de atendimento</h2>
|
||||
<p className="text-textSecondary text-sm leading-relaxed" style={{ whiteSpace: 'pre-line' }}>
|
||||
{info.business_hours}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-textTertiary">
|
||||
Deseja cadastrar seu imóvel conosco?{' '}
|
||||
<a href="/cadastro-residencia" className="text-[#5e6ad2] hover:underline font-medium">
|
||||
Clique aqui
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Form ─────────────────────────────────────────── */}
|
||||
<div className="bg-panel border border-borderSubtle rounded-2xl p-8">
|
||||
{success ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-textPrimary mb-2">Mensagem enviada!</h3>
|
||||
<p className="text-textSecondary text-sm mb-6">Retornaremos em breve pelo e-mail informado.</p>
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="text-sm text-[#5e6ad2] hover:underline"
|
||||
>
|
||||
Enviar outra mensagem
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Nome <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="Seu nome"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Telefone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="(11) 99999-0000"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
E-mail <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="seu@email.com"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Assunto
|
||||
</label>
|
||||
<select
|
||||
name="subject"
|
||||
value={form.subject}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
>
|
||||
<option value="">Selecione um assunto…</option>
|
||||
{ASSUNTOS.map((a) => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Mensagem <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
value={form.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={5}
|
||||
placeholder="Descreva como podemos ajudá-lo…"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-xs">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-brand hover:bg-accentHover text-white font-medium text-sm rounded-lg py-2.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Enviando…' : 'Enviar mensagem'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,8 +3,13 @@ import AgentsCarousel from '../components/AgentsCarousel'
|
|||
import Footer from '../components/Footer'
|
||||
import HomeScrollScene from '../components/HomeScrollScene'
|
||||
import Navbar from '../components/Navbar'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getHomepageConfig } from '../services/homepage'
|
||||
import { getFeaturedProperties } from '../services/properties'
|
||||
import { getAgents } from '../services/agents'
|
||||
import type { HomepageConfig } from '../types/homepage'
|
||||
import type { Property } from '../types/property'
|
||||
import type { Agent } from '../types/agent'
|
||||
|
||||
const FALLBACK_CONFIG: HomepageConfig = {
|
||||
hero_headline: 'Encontre o imóvel dos seus sonhos',
|
||||
|
|
@ -14,23 +19,68 @@ const FALLBACK_CONFIG: HomepageConfig = {
|
|||
featured_properties_limit: 6,
|
||||
}
|
||||
|
||||
const CFG_CACHE_KEY = 'homepage_config_v1'
|
||||
const CFG_CACHE_TTL = 5 * 60 * 1000
|
||||
|
||||
function getCachedConfig(): HomepageConfig | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CFG_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const { data, ts } = JSON.parse(raw) as { data: HomepageConfig; ts: number }
|
||||
if (Date.now() - ts > CFG_CACHE_TTL) return null
|
||||
return data
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function setCachedConfig(data: HomepageConfig): void {
|
||||
try {
|
||||
sessionStorage.setItem(CFG_CACHE_KEY, JSON.stringify({ data, ts: Date.now() }))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [config, setConfig] = useState<HomepageConfig>(FALLBACK_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [featuredProperties, setFeaturedProperties] = useState<Property[]>([])
|
||||
const [loadingProperties, setLoadingProperties] = useState(true)
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [loadingAgents, setLoadingAgents] = useState(true)
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const themedBackgroundImage = resolvedTheme === 'dark'
|
||||
? (config.hero_image_dark_url ?? config.hero_image_url ?? null)
|
||||
: (config.hero_image_light_url ?? config.hero_image_url ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
getHomepageConfig()
|
||||
.then((data) => {
|
||||
setConfig(data)
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fall back to FALLBACK_CONFIG — already set in useState
|
||||
const cached = getCachedConfig()
|
||||
const configFetch = cached
|
||||
? Promise.resolve(cached)
|
||||
: getHomepageConfig().then(d => { setCachedConfig(d); return d })
|
||||
|
||||
Promise.all([configFetch, getFeaturedProperties(), getAgents()])
|
||||
.then(([cfg, props, agts]) => {
|
||||
setConfig(cfg)
|
||||
setFeaturedProperties(props)
|
||||
setAgents(agts)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingProperties(false)
|
||||
setLoadingAgents(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!themedBackgroundImage) return
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'preload'
|
||||
link.as = 'image'
|
||||
link.href = themedBackgroundImage
|
||||
document.head.appendChild(link)
|
||||
return () => { document.head.removeChild(link) }
|
||||
}, [themedBackgroundImage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
|
@ -40,8 +90,10 @@ export default function HomePage() {
|
|||
subheadline={config.hero_subheadline ?? null}
|
||||
ctaLabel={config.hero_cta_label}
|
||||
ctaUrl={config.hero_cta_url}
|
||||
backgroundImage={config.hero_image_url ?? null}
|
||||
backgroundImage={themedBackgroundImage}
|
||||
isLoading={isLoading}
|
||||
properties={featuredProperties}
|
||||
loadingProperties={loadingProperties}
|
||||
/>
|
||||
|
||||
{/* ── Corretores Carousel ───────────────────────────────────── */}
|
||||
|
|
@ -67,7 +119,7 @@ export default function HomePage() {
|
|||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<AgentsCarousel />
|
||||
<AgentsCarousel agents={agents} loading={loadingAgents} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
316
frontend/src/pages/JobsPage.tsx
Normal file
316
frontend/src/pages/JobsPage.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Footer from '../components/Footer'
|
||||
import Navbar from '../components/Navbar'
|
||||
import { submitJobApplication, type JobApplicationPayload } from '../services/jobs'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'Corretor(a)',
|
||||
'Assistente Administrativo',
|
||||
'Estagiário(a)',
|
||||
'Outro',
|
||||
]
|
||||
|
||||
const BENEFITS = [
|
||||
{
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Equipe colaborativa',
|
||||
description: 'Trabalhe com profissionais experientes em um ambiente de apoio mútuo e crescimento constante.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Crescimento real',
|
||||
description: 'Plano de carreira claro, metas atingíveis e reconhecimento de resultados individuais e coletivos.',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" /><path d="M12 8v4l3 3" />
|
||||
</svg>
|
||||
),
|
||||
title: 'Flexibilidade',
|
||||
description: 'Horários adaptáveis, comissionamento competitivo e autonomia para gerenciar sua agenda.',
|
||||
},
|
||||
]
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
role_interest: string
|
||||
message: string
|
||||
file_name: string
|
||||
privacy: boolean
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role_interest: '',
|
||||
message: '',
|
||||
file_name: '',
|
||||
privacy: false,
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label, name, type = 'text', required = false, value, onChange, placeholder,
|
||||
}: {
|
||||
label: string
|
||||
name: string
|
||||
type?: string
|
||||
required?: boolean
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={name} className="text-sm font-medium text-textSecondary">
|
||||
{label}{required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className="h-10 rounded-lg border border-borderSubtle bg-surface px-3 text-sm text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 focus:border-[#5e6ad2] transition"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) {
|
||||
const { name, value, type } = e.target
|
||||
if (type === 'checkbox') {
|
||||
setForm((prev) => ({ ...prev, [name]: (e.target as HTMLInputElement).checked }))
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!form.privacy) {
|
||||
setError('Você precisa aceitar a política de privacidade.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload: JobApplicationPayload = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
phone: form.phone || undefined,
|
||||
role_interest: form.role_interest,
|
||||
message: form.message,
|
||||
file_name: form.file_name || undefined,
|
||||
}
|
||||
await submitJobApplication(payload)
|
||||
setSuccess(true)
|
||||
setForm(INITIAL)
|
||||
} catch {
|
||||
setError('Não foi possível enviar sua candidatura. Tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-canvas pt-14">
|
||||
|
||||
{/* Hero */}
|
||||
<section className="bg-panel border-b border-borderSubtle">
|
||||
<div className="max-w-[760px] mx-auto px-6 py-16 text-center">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#5e6ad2]/30 bg-[#5e6ad2]/10 px-3 py-1 text-xs font-medium text-[#5e6ad2] mb-5">
|
||||
Oportunidades
|
||||
</span>
|
||||
<h1 className="text-3xl sm:text-4xl font-semibold text-textPrimary tracking-tight mb-4">
|
||||
Trabalhe Conosco
|
||||
</h1>
|
||||
<p className="text-base text-textSecondary leading-relaxed max-w-[540px] mx-auto">
|
||||
Faça parte de um time apaixonado pelo mercado imobiliário. Envie seu currículo e conte-nos por que você quer crescer com a gente.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefícios */}
|
||||
<section className="max-w-[1000px] mx-auto px-6 py-14">
|
||||
<h2 className="text-lg font-semibold text-textPrimary mb-8 text-center">
|
||||
Por que trabalhar conosco?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{BENEFITS.map((b) => (
|
||||
<div key={b.title} className="rounded-xl border border-borderSubtle bg-panel p-6 flex flex-col gap-3">
|
||||
<span className="text-[#5e6ad2]">{b.icon}</span>
|
||||
<h3 className="text-sm font-semibold text-textPrimary">{b.title}</h3>
|
||||
<p className="text-sm text-textTertiary leading-relaxed">{b.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Formulário */}
|
||||
<section className="max-w-[680px] mx-auto px-6 pb-20">
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-8">
|
||||
<h2 className="text-lg font-semibold text-textPrimary mb-1">
|
||||
Envie sua candidatura
|
||||
</h2>
|
||||
<p className="text-sm text-textTertiary mb-6">
|
||||
Preencha os campos abaixo e entraremos em contato.
|
||||
</p>
|
||||
|
||||
{success ? (
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/10 p-6 text-center">
|
||||
<svg className="mx-auto mb-3 text-green-400" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-green-400">Candidatura enviada com sucesso!</p>
|
||||
<p className="text-xs text-textTertiary mt-1">Entraremos em contato em breve.</p>
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="mt-4 text-xs text-[#5e6ad2] hover:underline"
|
||||
>
|
||||
Enviar outra candidatura
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-5">
|
||||
{/* Dados pessoais */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Nome completo"
|
||||
name="name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Seu nome"
|
||||
/>
|
||||
<InputField
|
||||
label="E-mail"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
<InputField
|
||||
label="Telefone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="(11) 99999-9999"
|
||||
/>
|
||||
{/* Cargo de interesse */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="role_interest" className="text-sm font-medium text-textSecondary">
|
||||
Cargo de interesse<span className="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="role_interest"
|
||||
name="role_interest"
|
||||
required
|
||||
value={form.role_interest}
|
||||
onChange={handleChange}
|
||||
className="h-10 rounded-lg border border-borderSubtle bg-surface px-3 text-sm text-textPrimary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 focus:border-[#5e6ad2] transition"
|
||||
>
|
||||
<option value="">Selecione…</option>
|
||||
{ROLE_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nome do arquivo */}
|
||||
<InputField
|
||||
label="Nome do arquivo do currículo (PDF)"
|
||||
name="file_name"
|
||||
value={form.file_name}
|
||||
onChange={handleChange}
|
||||
placeholder="curriculo_joao_silva.pdf"
|
||||
/>
|
||||
|
||||
{/* Mensagem */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="message" className="text-sm font-medium text-textSecondary">
|
||||
Apresentação<span className="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
value={form.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Fale um pouco sobre você, sua experiência e por que quer trabalhar conosco…"
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-2.5 text-sm text-textPrimary placeholder:text-textQuaternary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 focus:border-[#5e6ad2] transition resize-none"
|
||||
/>
|
||||
<span className="text-xs text-textQuaternary text-right">
|
||||
{form.message.length}/5000
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Política */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="privacy"
|
||||
checked={form.privacy}
|
||||
onChange={handleChange}
|
||||
className="mt-0.5 h-4 w-4 rounded border-borderSubtle accent-[#5e6ad2]"
|
||||
/>
|
||||
<span className="text-xs text-textTertiary leading-relaxed">
|
||||
Li e aceito a{' '}
|
||||
<Link to="/politica-de-privacidade" className="text-[#5e6ad2] hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-[#5e6ad2] py-2.5 text-sm font-semibold text-white hover:bg-[#6872d8] transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Enviando…' : 'ENVIAR CANDIDATURA'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
|
|
@ -34,73 +35,117 @@ export default function LoginPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const demoCredentials = [
|
||||
{ label: 'Admin', email: 'admin@demo.com', password: 'admin1234', admin: true },
|
||||
{ label: 'Usuário', email: 'usuario@demo.com', password: 'demo1234', admin: false },
|
||||
]
|
||||
|
||||
function fillCredentials(cred: typeof demoCredentials[0]) {
|
||||
setEmail(cred.email)
|
||||
setPassword(cred.password)
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-canvas px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-textPrimary">Entrar</h1>
|
||||
<p className="mt-1 text-sm text-textSecondary">Acesse sua conta</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<Navbar />
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4"
|
||||
>
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] w-full max-w-sm items-center justify-center px-4 py-8 pt-20">
|
||||
<div className="w-full">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-semibold text-textPrimary">Entrar</h1>
|
||||
<p className="mt-1 text-sm text-textSecondary">Acesse sua conta</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
|
||||
Senha
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-brand py-2.5 text-sm font-medium text-white transition hover:bg-accentHover disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4"
|
||||
>
|
||||
{loading && (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-textTertiary">
|
||||
Não tem conta?{' '}
|
||||
<Link
|
||||
to="/cadastro"
|
||||
className="text-accent hover:text-accentHover transition"
|
||||
>
|
||||
Cadastre-se
|
||||
</Link>
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-textSecondary uppercase tracking-wide">
|
||||
Senha
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-brand py-2.5 text-sm font-medium text-white transition hover:bg-accentHover disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
)}
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-textTertiary">
|
||||
Não tem conta?{' '}
|
||||
<Link
|
||||
to="/cadastro"
|
||||
className="text-accent hover:text-accentHover transition"
|
||||
>
|
||||
Cadastre-se
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Demo credentials */}
|
||||
<div className="mt-6 rounded-xl border border-borderSubtle bg-panel/60 p-4 space-y-3">
|
||||
<p className="text-xs font-semibold text-textTertiary uppercase tracking-wide text-center">
|
||||
Acesso de demonstração
|
||||
</p>
|
||||
{demoCredentials.map((cred) => (
|
||||
<button
|
||||
key={cred.email}
|
||||
type="button"
|
||||
onClick={() => fillCredentials(cred)}
|
||||
className="w-full flex items-center justify-between gap-3 rounded-lg border border-borderSubtle bg-surface hover:bg-panel px-3 py-2.5 transition text-left"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-textPrimary">{cred.label}</span>
|
||||
{cred.admin && (
|
||||
<span className="rounded-full bg-brand/20 text-brand text-[10px] font-semibold px-1.5 py-0.5">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-textTertiary truncate">{cred.email}</p>
|
||||
<p className="text-[11px] text-textTertiary font-mono">{cred.password}</p>
|
||||
</div>
|
||||
<span className="text-xs text-accent shrink-0">Usar →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,16 @@ export default function PropertiesPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [filters, setFilters] = useState<PropertyFilters>(() => filtersFromParams(searchParams))
|
||||
|
||||
// Sync filters when URL changes externally (e.g. navbar "Comprar"/"Alugar" links)
|
||||
const prevSearchParamsRef = useRef(searchParams.toString())
|
||||
useEffect(() => {
|
||||
const current = searchParams.toString()
|
||||
if (current !== prevSearchParamsRef.current) {
|
||||
prevSearchParamsRef.current = current
|
||||
setFilters(filtersFromParams(searchParams))
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const [result, setResult] = useState<PaginatedProperties | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -363,10 +373,10 @@ export default function PropertiesPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter button */}
|
||||
{/* Mobile filter button — only on small screens */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="flex items-center gap-2 h-8 px-3 rounded-lg border border-borderSubtle text-sm text-textSecondary hover:border-borderStandard transition-colors"
|
||||
className="lg:hidden flex items-center gap-2 h-8 px-3 rounded-lg border border-borderSubtle text-sm text-textSecondary hover:border-borderStandard transition-colors"
|
||||
>
|
||||
<FilterIcon />
|
||||
Filtros
|
||||
|
|
@ -383,116 +393,134 @@ export default function PropertiesPage() {
|
|||
|
||||
{/* ── Main content ─────────────────────────────────────────── */}
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6">
|
||||
{/* Results area */}
|
||||
<div ref={resultsRef} className="w-full">
|
||||
{/* Active filter chips */}
|
||||
<ActiveFiltersBar
|
||||
filters={filters}
|
||||
catalog={{ propertyTypes, cities, neighborhoods, imobiliarias }}
|
||||
onFilterChange={handleFiltersChange}
|
||||
/>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-textSecondary text-base mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => fetchProperties(filters)}
|
||||
className="mt-4 text-sm font-medium text-brand hover:underline"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
<div className="flex gap-6 items-start">
|
||||
{/* Sidebar fixa — visível apenas em lg+ */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0 sticky top-[120px]">
|
||||
<div className="bg-panel border border-borderSubtle rounded-xl p-4 overflow-y-auto max-h-[calc(100vh-140px)]">
|
||||
<FilterSidebar
|
||||
propertyTypes={propertyTypes}
|
||||
amenities={amenities}
|
||||
cities={cities}
|
||||
neighborhoods={neighborhoods}
|
||||
imobiliarias={imobiliarias}
|
||||
filters={filters}
|
||||
onChange={handleFiltersChange}
|
||||
onClear={handleClear}
|
||||
catalogLoading={catalogLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
{/* Results area */}
|
||||
<div ref={resultsRef} className="flex-1 min-w-0">
|
||||
{/* Active filter chips */}
|
||||
<ActiveFiltersBar
|
||||
filters={filters}
|
||||
catalog={{ propertyTypes, cities, neighborhoods, imobiliarias }}
|
||||
onFilterChange={handleFiltersChange}
|
||||
/>
|
||||
|
||||
{!error && (
|
||||
<>
|
||||
{/* Top pagination (only after first load with multiple pages) */}
|
||||
{result && result.pages > 1 && !loading && (
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
current={result.page}
|
||||
total={result.pages}
|
||||
onChange={handlePageChange}
|
||||
ariaLabel="Paginação superior"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-textSecondary text-base mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => fetchProperties(filters)}
|
||||
className="mt-4 text-sm font-medium text-brand hover:underline"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First load: skeleton. Filter change: opacity overlay */}
|
||||
{loading && !result ? (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<PropertyGridSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<PropertyRowSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : result && result.items.length > 0 ? (
|
||||
<div className={`transition-opacity duration-150 ${loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}`}>
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{result.items.map((property, i) => (
|
||||
<div
|
||||
key={property.id}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<PropertyGridCard property={property} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{result.items.map((property, i) => (
|
||||
<div
|
||||
key={property.id}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<PropertyRowCard property={property} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Position indicator */}
|
||||
{showPositionIndicator && (
|
||||
<p className="text-xs text-textTertiary text-center mt-6">
|
||||
Exibindo {from}–{to} de {result.total} imóveis
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bottom pagination */}
|
||||
<div className="mt-4">
|
||||
{!error && (
|
||||
<>
|
||||
{/* Top pagination (only after first load with multiple pages) */}
|
||||
{result && result.pages > 1 && !loading && (
|
||||
<div className="mb-4">
|
||||
<Pagination
|
||||
current={result.page}
|
||||
total={result.pages}
|
||||
onChange={handlePageChange}
|
||||
ariaLabel="Paginação"
|
||||
ariaLabel="Paginação superior"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : !loading ? (
|
||||
<EmptyStateWithSuggestions
|
||||
hasFilters={hasActiveFilters(filters)}
|
||||
suggestions={suggestions}
|
||||
onClearAll={handleClear}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* First load: skeleton. Filter change: opacity overlay */}
|
||||
{loading && !result ? (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<PropertyGridSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<PropertyRowSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : result && result.items.length > 0 ? (
|
||||
<div className={`transition-opacity duration-150 ${loading ? 'opacity-50 pointer-events-none' : 'opacity-100'}`}>
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{result.items.map((property, i) => (
|
||||
<div
|
||||
key={property.id}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<PropertyGridCard property={property} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{result.items.map((property, i) => (
|
||||
<div
|
||||
key={property.id}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<PropertyRowCard property={property} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Position indicator */}
|
||||
{showPositionIndicator && (
|
||||
<p className="text-xs text-textTertiary text-center mt-6">
|
||||
Exibindo {from}–{to} de {result.total} imóveis
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bottom pagination */}
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
current={result.page}
|
||||
total={result.pages}
|
||||
onChange={handlePageChange}
|
||||
ariaLabel="Paginação"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : !loading ? (
|
||||
<EmptyStateWithSuggestions
|
||||
hasFilters={hasActiveFilters(filters)}
|
||||
suggestions={suggestions}
|
||||
onClearAll={handleClear}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Sidebar overlay */}
|
||||
{/* Sidebar overlay — mobile only */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -114,7 +114,10 @@ export default function PropertyDetailPage() {
|
|||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-semibold text-textPrimary tracking-tight leading-tight mb-2 flex items-center gap-2">
|
||||
{property.title}
|
||||
<HeartButton propertyId={property.id} />
|
||||
<HeartButton
|
||||
propertyId={property.id}
|
||||
snapshot={{ id: property.id, slug: property.slug, title: property.title, price: property.price, type: property.type, photo: property.photos?.[0]?.url ?? null, city: property.city?.name ?? null, bedrooms: property.bedrooms, area_m2: property.area_m2 }}
|
||||
/>
|
||||
</h1>
|
||||
<div className="flex items-center flex-wrap gap-3">
|
||||
{property.code && (
|
||||
|
|
|
|||
78
frontend/src/pages/PublicFavoritesPage.tsx
Normal file
78
frontend/src/pages/PublicFavoritesPage.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Link, Navigate } from 'react-router-dom';
|
||||
import FavoritesCardsGrid from '../components/FavoritesCardsGrid';
|
||||
import Navbar from '../components/Navbar';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useFavorites } from '../contexts/FavoritesContext';
|
||||
|
||||
export default function PublicFavoritesPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { localEntries, favoriteIds } = useFavorites();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<Navbar />
|
||||
<div className="max-w-4xl mx-auto px-4 pt-20 pb-10">
|
||||
<div className="h-7 w-40 animate-pulse rounded-md bg-surface mb-6" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-borderSubtle bg-panel h-64 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If authenticated, redirect to client area favorites
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/area-do-cliente/favoritos" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<Navbar />
|
||||
<div className="max-w-4xl mx-auto px-4 pt-20 pb-10">
|
||||
<div className="mb-8 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-textPrimary">Meus Favoritos</h1>
|
||||
<p className="text-sm text-textTertiary mt-1">
|
||||
{favoriteIds.size} {favoriteIds.size === 1 ? 'imóvel salvo' : 'imóveis salvos'} localmente
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition shrink-0">
|
||||
← Voltar à listagem
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Banner — incentivo ao cadastro */}
|
||||
<div className="mb-6 rounded-xl border border-brand/30 bg-brand/5 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-textPrimary">Sincronize seus favoritos</p>
|
||||
<p className="text-xs text-textTertiary mt-0.5">
|
||||
Crie uma conta gratuita para acessar seus favoritos em qualquer dispositivo.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Link
|
||||
to="/cadastro"
|
||||
state={{ from: { pathname: '/area-do-cliente/favoritos' } }}
|
||||
className="rounded-lg bg-brand px-4 py-2 text-xs font-semibold text-white hover:bg-accentHover transition"
|
||||
>
|
||||
Criar conta
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
state={{ from: { pathname: '/area-do-cliente/favoritos' } }}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-4 py-2 text-xs font-semibold text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FavoritesCardsGrid entries={localEntries} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
frontend/src/pages/admin/AdminContactConfigPage.tsx
Normal file
208
frontend/src/pages/admin/AdminContactConfigPage.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { getContactConfig, updateContactConfig, type ContactConfig } from '../../services/contactConfig'
|
||||
|
||||
const INITIAL: ContactConfig = {
|
||||
address_street: '',
|
||||
address_neighborhood_city: '',
|
||||
address_zip: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
business_hours: '',
|
||||
}
|
||||
|
||||
function nullToEmpty(cfg: ContactConfig): ContactConfig {
|
||||
return {
|
||||
address_street: cfg.address_street ?? '',
|
||||
address_neighborhood_city: cfg.address_neighborhood_city ?? '',
|
||||
address_zip: cfg.address_zip ?? '',
|
||||
phone: cfg.phone ?? '',
|
||||
email: cfg.email ?? '',
|
||||
business_hours: cfg.business_hours ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminContactConfigPage() {
|
||||
const [form, setForm] = useState<ContactConfig>(INITIAL)
|
||||
const [loadingData, setLoadingData] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getContactConfig()
|
||||
.then((data) => setForm(nullToEmpty(data)))
|
||||
.catch(() => setError('Erro ao carregar configurações'))
|
||||
.finally(() => setLoadingData(false))
|
||||
}, [])
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
setSuccess(false)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
try {
|
||||
const updated = await updateContactConfig(form)
|
||||
setForm(nullToEmpty(updated))
|
||||
setSuccess(true)
|
||||
} catch {
|
||||
setError('Não foi possível salvar as configurações. Tente novamente.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingData) {
|
||||
return (
|
||||
<div className="p-6 md:p-8 flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-[680px]">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-textPrimary">Configurações da Página de Contato</h2>
|
||||
<p className="text-textTertiary text-sm mt-1">
|
||||
Edite as informações exibidas na página pública <span className="font-medium text-textSecondary">/contato</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Endereço */}
|
||||
<fieldset className="border border-borderSubtle rounded-xl p-5 space-y-4">
|
||||
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
|
||||
Endereço
|
||||
</legend>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Rua e número
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address_street"
|
||||
value={form.address_street ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="Rua das Imobiliárias, 123"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Bairro, cidade e estado
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address_neighborhood_city"
|
||||
value={form.address_neighborhood_city ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="Centro — São Paulo, SP"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
CEP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address_zip"
|
||||
value={form.address_zip ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="CEP 01000-000"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Contato */}
|
||||
<fieldset className="border border-borderSubtle rounded-xl p-5 space-y-4">
|
||||
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
|
||||
Contato
|
||||
</legend>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Telefone
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="phone"
|
||||
value={form.phone ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="(11) 99999-0000"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="contato@imobiliariahub.com.br"
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Horário */}
|
||||
<fieldset className="border border-borderSubtle rounded-xl p-5">
|
||||
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
|
||||
Horário de atendimento
|
||||
</legend>
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
Texto livre (use Enter para cada linha)
|
||||
</label>
|
||||
<textarea
|
||||
name="business_hours"
|
||||
value={form.business_hours ?? ''}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
placeholder={"Segunda a sexta: 9h às 18h\nSábados: 9h às 13h\nDomingos e feriados: fechado"}
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40 resize-none"
|
||||
/>
|
||||
<p className="text-xs text-textTertiary mt-1">
|
||||
Cada linha será exibida separada na página pública.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 text-green-400 text-sm rounded-lg px-4 py-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Configurações salvas com sucesso!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-brand hover:bg-accentHover text-white font-medium text-sm rounded-lg px-6 py-2.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Salvando…' : 'Salvar alterações'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
382
frontend/src/pages/admin/AdminHomepageConfigPage.tsx
Normal file
382
frontend/src/pages/admin/AdminHomepageConfigPage.tsx
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
getHomepageConfig,
|
||||
uploadHomepageHeroImage,
|
||||
updateHomepageHeroImages,
|
||||
} from '../../services/homepage'
|
||||
|
||||
type FormState = {
|
||||
hero_image_url: string
|
||||
hero_image_light_url: string
|
||||
hero_image_dark_url: string
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
hero_image_url: '',
|
||||
hero_image_light_url: '',
|
||||
hero_image_dark_url: '',
|
||||
}
|
||||
|
||||
const DEFAULT_HERO_IMAGE_LIGHT_URL =
|
||||
'https://images.unsplash.com/photo-1512918728675-ed5a9ecdebfd?auto=format&fit=crop&w=1920&q=80'
|
||||
const DEFAULT_HERO_IMAGE_DARK_URL =
|
||||
'https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?auto=format&fit=crop&w=1920&q=80'
|
||||
|
||||
function nullToEmpty(v?: string | null): string {
|
||||
return v ?? ''
|
||||
}
|
||||
|
||||
function buildDownloadName(url: string, fallbackName: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const pathname = parsed.pathname.split('/').filter(Boolean)
|
||||
const lastSegment = pathname[pathname.length - 1]
|
||||
if (!lastSegment) return fallbackName
|
||||
return decodeURIComponent(lastSegment)
|
||||
} catch {
|
||||
return fallbackName
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminHomepageConfigPage() {
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const [loadingData, setLoadingData] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingField, setUploadingField] = useState<keyof FormState | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getHomepageConfig()
|
||||
.then((data) => {
|
||||
setForm({
|
||||
hero_image_url: nullToEmpty(data.hero_image_url),
|
||||
hero_image_light_url: nullToEmpty(data.hero_image_light_url),
|
||||
hero_image_dark_url: nullToEmpty(data.hero_image_dark_url),
|
||||
})
|
||||
})
|
||||
.catch(() => setError('Erro ao carregar configurações da home'))
|
||||
.finally(() => setLoadingData(false))
|
||||
}, [])
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
setSuccess(false)
|
||||
}
|
||||
|
||||
async function handleUpload(
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: keyof FormState,
|
||||
) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploadingField(field)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const uploaded = await uploadHomepageHeroImage(file)
|
||||
setForm((prev) => ({ ...prev, [field]: uploaded.url }))
|
||||
} catch {
|
||||
setError('Não foi possível enviar a imagem. Tente novamente.')
|
||||
} finally {
|
||||
setUploadingField(null)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreviewCard(label: string, url: string | null, fallbackHint?: string) {
|
||||
return (
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-3">
|
||||
<p className="text-xs font-medium text-textSecondary mb-2">{label}</p>
|
||||
<div className="h-28 w-full overflow-hidden rounded-lg bg-surface border border-borderSubtle">
|
||||
{url ? (
|
||||
<img src={url} alt={label} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-xs text-textTertiary">
|
||||
Sem imagem definida
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{fallbackHint && (
|
||||
<p className="mt-2 text-[11px] text-textTertiary">{fallbackHint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
try {
|
||||
const updated = await updateHomepageHeroImages({
|
||||
hero_image_url: form.hero_image_url || null,
|
||||
hero_image_light_url: form.hero_image_light_url || null,
|
||||
hero_image_dark_url: form.hero_image_dark_url || null,
|
||||
})
|
||||
setForm({
|
||||
hero_image_url: nullToEmpty(updated.hero_image_url),
|
||||
hero_image_light_url: nullToEmpty(updated.hero_image_light_url),
|
||||
hero_image_dark_url: nullToEmpty(updated.hero_image_dark_url),
|
||||
})
|
||||
setSuccess(true)
|
||||
} catch {
|
||||
setError('Não foi possível salvar as imagens da home. Tente novamente.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingData) {
|
||||
return (
|
||||
<div className="p-6 md:p-8 flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-[760px]">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-textPrimary">Configuração da Home</h2>
|
||||
<p className="text-textTertiary text-sm mt-1">
|
||||
Defina imagens de fundo separadas para os temas claro e escuro na seção hero da página inicial.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{renderPreviewCard(
|
||||
'Preview fallback (legado)',
|
||||
form.hero_image_url || null,
|
||||
'Usada quando o tema específico não estiver configurado.',
|
||||
)}
|
||||
{renderPreviewCard(
|
||||
'Preview tema light',
|
||||
form.hero_image_light_url || form.hero_image_url || null,
|
||||
form.hero_image_light_url
|
||||
? 'Imagem light específica definida.'
|
||||
: 'Sem light específica: usando fallback legado.',
|
||||
)}
|
||||
{renderPreviewCard(
|
||||
'Preview tema dark',
|
||||
form.hero_image_dark_url || form.hero_image_url || null,
|
||||
form.hero_image_dark_url
|
||||
? 'Imagem dark específica definida.'
|
||||
: 'Sem dark específica: usando fallback legado.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<fieldset className="border border-borderSubtle rounded-xl p-5 space-y-4">
|
||||
<legend className="px-2 text-xs font-semibold text-textSecondary uppercase tracking-wider">
|
||||
Hero Background
|
||||
</legend>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
URL fallback (legado)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="hero_image_url"
|
||||
value={form.hero_image_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://..."
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
<p className="text-xs text-textTertiary mt-1">
|
||||
Usada como fallback quando a imagem específica do tema não estiver preenchida.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
|
||||
{uploadingField === 'hero_image_url' ? 'Enviando…' : 'Enviar nova imagem fallback'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e, 'hero_image_url')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
URL imagem tema claro (light)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="hero_image_light_url"
|
||||
value={form.hero_image_light_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://..."
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{form.hero_image_light_url && (
|
||||
<a
|
||||
href={form.hero_image_light_url}
|
||||
download={buildDownloadName(form.hero_image_light_url, 'home-hero-light.jpg')}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Baixar imagem light atual
|
||||
</a>
|
||||
)}
|
||||
{!!form.hero_image_url && form.hero_image_url !== form.hero_image_light_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, hero_image_light_url: prev.hero_image_url }))
|
||||
setSuccess(false)
|
||||
}}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Usar fallback como light
|
||||
</button>
|
||||
)}
|
||||
{!!form.hero_image_dark_url && form.hero_image_dark_url !== form.hero_image_light_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({ ...prev, hero_image_light_url: prev.hero_image_dark_url }))
|
||||
setSuccess(false)
|
||||
}}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Usar imagem dark como light
|
||||
</button>
|
||||
)}
|
||||
{!form.hero_image_light_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hero_image_light_url: DEFAULT_HERO_IMAGE_LIGHT_URL,
|
||||
}))
|
||||
setSuccess(false)
|
||||
}}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Preencher light com fallback do sistema
|
||||
</button>
|
||||
)}
|
||||
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
|
||||
{uploadingField === 'hero_image_light_url' ? 'Enviando…' : 'Criar nova imagem light (upload)'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e, 'hero_image_light_url')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-textSecondary mb-1">
|
||||
URL imagem tema escuro (dark)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="hero_image_dark_url"
|
||||
value={form.hero_image_dark_url}
|
||||
onChange={handleChange}
|
||||
placeholder="https://..."
|
||||
className="w-full bg-canvas border border-borderSubtle rounded-lg px-3 py-2.5 text-sm text-textPrimary placeholder:text-textTertiary focus:outline-none focus:ring-2 focus:ring-[#5e6ad2]/40"
|
||||
/>
|
||||
{form.hero_image_dark_url && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<a
|
||||
href={form.hero_image_dark_url}
|
||||
download={buildDownloadName(form.hero_image_dark_url, 'home-hero-dark.jpg')}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Baixar imagem dark atual
|
||||
</a>
|
||||
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
|
||||
{uploadingField === 'hero_image_dark_url' ? 'Enviando…' : 'Criar nova imagem dark (upload)'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e, 'hero_image_dark_url')}
|
||||
/>
|
||||
</label>
|
||||
{!form.hero_image_dark_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hero_image_dark_url: DEFAULT_HERO_IMAGE_DARK_URL,
|
||||
}))
|
||||
setSuccess(false)
|
||||
}}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Preencher dark com fallback do sistema
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!form.hero_image_dark_url && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<label className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition cursor-pointer">
|
||||
{uploadingField === 'hero_image_dark_url' ? 'Enviando…' : 'Criar nova imagem dark (upload)'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e, 'hero_image_dark_url')}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hero_image_dark_url: DEFAULT_HERO_IMAGE_DARK_URL,
|
||||
}))
|
||||
setSuccess(false)
|
||||
}}
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-3 py-1.5 text-xs font-medium text-textPrimary hover:bg-panel transition"
|
||||
>
|
||||
Preencher dark com fallback do sistema
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-500/10 text-green-400 text-sm rounded-lg px-4 py-3 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Configuração da home salva com sucesso!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-brand hover:bg-accentHover text-white font-medium text-sm rounded-lg px-6 py-2.5 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Salvando…' : 'Salvar alterações'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
231
frontend/src/pages/admin/AdminJobsPage.tsx
Normal file
231
frontend/src/pages/admin/AdminJobsPage.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import api from '../../services/api'
|
||||
|
||||
interface JobApplication {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
role_interest: string
|
||||
message: string
|
||||
file_name: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface PaginatedJobs {
|
||||
items: JobApplication[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Pendente',
|
||||
reviewed: 'Revisado',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-amber-500/10 text-amber-400',
|
||||
reviewed: 'bg-emerald-500/10 text-emerald-400',
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function AdminJobsPage() {
|
||||
const [items, setItems] = useState<JobApplication[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pages, setPages] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<number | null>(null)
|
||||
|
||||
function fetchJobs(p = 1) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
api.get<PaginatedJobs>('/admin/jobs', { params: { page: p, per_page: 20 } })
|
||||
.then((res) => {
|
||||
setItems(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
setPage(res.data.page)
|
||||
setPages(res.data.pages)
|
||||
})
|
||||
.catch(() => setError('Erro ao carregar candidaturas'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs(1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-textPrimary">Candidaturas</h2>
|
||||
<p className="text-textTertiary text-sm mt-0.5">
|
||||
{total} candidatura{total !== 1 ? 's' : ''} recebida{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 text-textTertiary text-sm">
|
||||
Nenhuma candidatura recebida ainda.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border border-borderSubtle">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-borderSubtle bg-surface">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">Nome</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden md:table-cell">E-mail</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden lg:table-cell">Telefone</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden md:table-cell">Cargo</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden xl:table-cell">Currículo</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider whitespace-nowrap">Data</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => {
|
||||
const statusColor = STATUS_COLORS[item.status] ?? 'bg-gray-500/10 text-gray-400'
|
||||
const statusLabel = STATUS_LABELS[item.status] ?? item.status
|
||||
const isOpen = expanded === item.id
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`border-b border-borderSubtle ${isOpen ? '' : 'last:border-0'} ${i % 2 === 0 ? 'bg-canvas' : 'bg-surface'}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textPrimary font-medium whitespace-nowrap">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textSecondary hidden md:table-cell">
|
||||
<a href={`mailto:${item.email}`} className="hover:text-textPrimary transition-colors">
|
||||
{item.email}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textSecondary hidden lg:table-cell whitespace-nowrap">
|
||||
{item.phone
|
||||
? <a href={`tel:${item.phone}`} className="hover:text-textPrimary transition-colors">{item.phone}</a>
|
||||
: '—'
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-[#5e6ad2]/10 text-[#5e6ad2]">
|
||||
{item.role_interest}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textTertiary text-xs hidden xl:table-cell max-w-[160px]">
|
||||
{item.file_name
|
||||
? <span className="flex items-center gap-1 truncate" title={item.file_name}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg>
|
||||
{item.file_name}
|
||||
</span>
|
||||
: '—'
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textTertiary text-xs whitespace-nowrap">
|
||||
{formatDate(item.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setExpanded(isOpen ? null : item.id)}
|
||||
aria-label={isOpen ? 'Recolher' : 'Ver apresentação'}
|
||||
className="text-textQuaternary hover:text-textSecondary transition-colors"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Linha expandida com a mensagem */}
|
||||
{isOpen && (
|
||||
<tr key={`${item.id}-expanded`} className={`border-b border-borderSubtle ${i % 2 === 0 ? 'bg-canvas' : 'bg-surface'}`}>
|
||||
<td colSpan={8} className="px-6 pb-5 pt-2">
|
||||
<p className="text-xs font-medium text-textTertiary uppercase tracking-wider mb-2">Apresentação</p>
|
||||
<p className="text-sm text-textSecondary leading-relaxed whitespace-pre-wrap bg-panel rounded-lg p-4 border border-borderSubtle">
|
||||
{item.message}
|
||||
</p>
|
||||
{/* Mobile: campos ocultos na tabela */}
|
||||
<div className="flex flex-wrap gap-4 mt-3 md:hidden">
|
||||
<div>
|
||||
<p className="text-xs text-textQuaternary mb-0.5">E-mail</p>
|
||||
<a href={`mailto:${item.email}`} className="text-sm text-textSecondary hover:text-textPrimary">{item.email}</a>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-textQuaternary mb-0.5">Cargo</p>
|
||||
<p className="text-sm text-textSecondary">{item.role_interest}</p>
|
||||
</div>
|
||||
{item.file_name && (
|
||||
<div>
|
||||
<p className="text-xs text-textQuaternary mb-0.5">Currículo</p>
|
||||
<p className="text-sm text-textSecondary">{item.file_name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Paginação */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-textTertiary">
|
||||
<span>Página {page} de {pages}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => { const p = page - 1; setPage(p); fetchJobs(p) }}
|
||||
className="px-3 py-1.5 rounded-lg border border-borderSubtle hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => { const p = page + 1; setPage(p); fetchJobs(p) }}
|
||||
className="px-3 py-1.5 rounded-lg border border-borderSubtle hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Próxima
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
frontend/src/pages/admin/AdminLeadsPage.tsx
Normal file
237
frontend/src/pages/admin/AdminLeadsPage.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import api from '../../services/api'
|
||||
|
||||
interface Lead {
|
||||
id: number
|
||||
property_id: string | null
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
message: string
|
||||
source: string | null
|
||||
source_detail: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface PaginatedLeads {
|
||||
items: Lead[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
contato: 'Contato',
|
||||
imovel: 'Imóvel',
|
||||
cadastro_residencia: 'Cadastro',
|
||||
}
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
contato: 'bg-blue-500/10 text-blue-400',
|
||||
imovel: 'bg-purple-500/10 text-purple-400',
|
||||
cadastro_residencia: 'bg-emerald-500/10 text-emerald-400',
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ value: '', label: 'Todos' },
|
||||
{ value: 'contato', label: 'Contato' },
|
||||
{ value: 'imovel', label: 'Imóvel' },
|
||||
{ value: 'cadastro_residencia', label: 'Cadastro' },
|
||||
]
|
||||
|
||||
export default function AdminLeadsPage() {
|
||||
const [leads, setLeads] = useState<Lead[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pages, setPages] = useState(1)
|
||||
const [source, setSource] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function fetchLeads(p = 1, src = source) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params: Record<string, string | number> = { page: p, per_page: 20 }
|
||||
if (src) params.source = src
|
||||
api.get<PaginatedLeads>('/admin/leads', { params })
|
||||
.then((res) => {
|
||||
setLeads(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
setPage(res.data.page)
|
||||
setPages(res.data.pages)
|
||||
})
|
||||
.catch(() => setError('Erro ao carregar leads'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeads(1, source)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [source])
|
||||
|
||||
function handleFilterChange(val: string) {
|
||||
setSource(val)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-textPrimary">Central de Leads</h2>
|
||||
<p className="text-textTertiary text-sm mt-0.5">
|
||||
{total} lead{total !== 1 ? 's' : ''} encontrado{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros por origem */}
|
||||
<div className="flex gap-2 flex-wrap mb-6">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => handleFilterChange(f.value)}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-colors border ${source === f.value
|
||||
? 'bg-brand text-white border-brand'
|
||||
: 'bg-transparent text-textSecondary border-borderSubtle hover:border-brand/40'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-brand border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : leads.length === 0 ? (
|
||||
<div className="text-center py-16 text-textTertiary text-sm">
|
||||
Nenhum lead encontrado.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border border-borderSubtle">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-borderSubtle bg-surface">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">
|
||||
Origem
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider">
|
||||
Nome
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden md:table-cell">
|
||||
E-mail
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden lg:table-cell">
|
||||
Telefone
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden xl:table-cell">
|
||||
Mensagem
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider hidden lg:table-cell">
|
||||
Detalhe
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-textTertiary uppercase tracking-wider whitespace-nowrap">
|
||||
Data
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead, i) => {
|
||||
const src = lead.source ?? 'contato'
|
||||
const colorClass = SOURCE_COLORS[src] ?? 'bg-gray-500/10 text-gray-400'
|
||||
const srcLabel = SOURCE_LABELS[src] ?? src
|
||||
return (
|
||||
<tr
|
||||
key={lead.id}
|
||||
className={`border-b border-borderSubtle last:border-0 ${i % 2 === 0 ? 'bg-canvas' : 'bg-surface'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}
|
||||
>
|
||||
{srcLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textPrimary font-medium">
|
||||
{lead.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textSecondary hidden md:table-cell">
|
||||
{lead.email}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textSecondary hidden lg:table-cell">
|
||||
{lead.phone ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textSecondary hidden xl:table-cell max-w-[240px]">
|
||||
<span
|
||||
className="block truncate"
|
||||
title={lead.message}
|
||||
>
|
||||
{lead.message}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textTertiary hidden lg:table-cell max-w-[160px]">
|
||||
<span
|
||||
className="block truncate text-xs"
|
||||
title={lead.source_detail ?? ''}
|
||||
>
|
||||
{lead.source_detail ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-textTertiary text-xs whitespace-nowrap">
|
||||
{formatDate(lead.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Paginação */}
|
||||
{pages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => fetchLeads(page - 1)}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-borderSubtle text-textSecondary hover:border-brand/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="px-3 py-1.5 text-xs text-textSecondary">
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => fetchLeads(page + 1)}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-borderSubtle text-textSecondary hover:border-brand/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Próxima
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,100 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { getBoletos } from '../../services/clientArea';
|
||||
import type { Boleto } from '../../types/clientArea';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'Pendente', color: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
|
||||
paid: { label: 'Pago', color: 'bg-green-500/10 text-green-400 border-green-500/20' },
|
||||
overdue: { label: 'Vencido', color: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||
};
|
||||
|
||||
export default function BoletosPage() {
|
||||
const [boletos, setBoletos] = useState<Boleto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getBoletos()
|
||||
.then(setBoletos)
|
||||
.catch(() => setBoletos([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function formatCurrency(amount: number | string) {
|
||||
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(num);
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Intl.DateTimeFormat('pt-BR').format(new Date(d + 'T00:00:00'));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-7 w-40 animate-pulse rounded-md bg-white/[0.06]" />
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-xl bg-panel border border-borderSubtle" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<h1 className="text-xl font-semibold text-textPrimary mb-6">Meus Boletos</h1>
|
||||
|
||||
{boletos.length === 0 ? (
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
|
||||
<p className="text-textTertiary">Nenhum boleto disponível</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-borderSubtle">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Descrição</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Imóvel</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-textTertiary uppercase tracking-wide">Valor</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Vencimento</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-textTertiary uppercase tracking-wide">Ação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{boletos.map(boleto => {
|
||||
const status = STATUS_LABELS[boleto.status] ?? { label: boleto.status, color: 'bg-white/10 text-white/60 border-white/10' };
|
||||
return (
|
||||
<tr key={boleto.id} className="border-b border-borderSubtle hover:bg-surface transition">
|
||||
<td className="px-4 py-3 text-textPrimary">{boleto.description}</td>
|
||||
<td className="px-4 py-3 text-textSecondary text-xs">{boleto.property?.title ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-right text-textPrimary font-medium">{formatCurrency(boleto.amount)}</td>
|
||||
<td className="px-4 py-3 text-textSecondary">{formatDate(boleto.due_date)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{boleto.url ? (
|
||||
<a
|
||||
href={boleto.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-accent hover:text-accentHover transition"
|
||||
>
|
||||
Ver boleto →
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-textQuaternary">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { getBoletos, getFavorites, getVisits } from '../../services/clientArea';
|
||||
|
||||
export default function ClientDashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const [counts, setCounts] = useState({ favorites: 0, visits: 0, boletos: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getFavorites(), getVisits(), getBoletos()])
|
||||
.then(([favs, visits, boletos]) => {
|
||||
setCounts({
|
||||
favorites: Array.isArray(favs) ? favs.length : 0,
|
||||
visits: visits.filter(v => v.status === 'pending' || v.status === 'confirmed').length,
|
||||
boletos: boletos.filter(b => b.status === 'pending').length,
|
||||
});
|
||||
})
|
||||
.catch(() => { })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const cards = [
|
||||
{ label: 'Favoritos', value: counts.favorites, to: '/area-do-cliente/favoritos', color: 'text-red-400' },
|
||||
{ label: 'Visitas ativas', value: counts.visits, to: '/area-do-cliente/visitas', color: 'text-blue-400' },
|
||||
{ label: 'Boletos pendentes', value: counts.boletos, to: '/area-do-cliente/boletos', color: 'text-yellow-400' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<h1 className="text-xl font-semibold text-textPrimary mb-1">Olá, {user?.name?.split(' ')[0]}</h1>
|
||||
<p className="text-sm text-textTertiary mb-8">Bem-vindo à sua área do cliente</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{cards.map(card => (
|
||||
<Link
|
||||
key={card.to}
|
||||
to={card.to}
|
||||
className="group rounded-xl border border-borderSubtle bg-panel p-5 hover:border-borderStandard transition"
|
||||
>
|
||||
<p className="text-sm text-textSecondary mb-2">{card.label}</p>
|
||||
{loading ? (
|
||||
<div className="h-8 w-16 animate-pulse rounded-md bg-surface" />
|
||||
) : (
|
||||
<p className={`text-3xl font-semibold ${card.color}`}>{card.value}</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,9 +20,22 @@ export default function ComparisonPage() {
|
|||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-semibold text-textPrimary mb-6">Comparar Imóveis</h1>
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
|
||||
<p className="text-textTertiary mb-4">Nenhum imóvel selecionado para comparação</p>
|
||||
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center max-w-sm mx-auto">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
strokeWidth={1.5} stroke="currentColor" className="size-6 text-textTertiary">
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 15.95M5.25 4.97l-2.62 15.95m0 0a48.959 48.959 0 0 0 3.32.65M5.63 20.92a48.958 48.958 0 0 0 3.32-.65" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-textPrimary mb-2">Compare imóveis lado a lado</p>
|
||||
<p className="text-xs text-textTertiary mb-4">
|
||||
Para adicionar um imóvel à comparação, clique no ícone ⇄ nos cards de imóveis. Você pode comparar até 3 imóveis simultaneamente.
|
||||
</p>
|
||||
<Link
|
||||
to="/imoveis"
|
||||
className="inline-block rounded-lg bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accentHover transition"
|
||||
>
|
||||
Explorar imóveis →
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import HeartButton from '../../components/HeartButton';
|
||||
import FavoritesCardsGrid, { type FavoriteCardEntry } from '../../components/FavoritesCardsGrid';
|
||||
import { useFavorites } from '../../contexts/FavoritesContext';
|
||||
import { getFavorites } from '../../services/clientArea';
|
||||
import type { SavedProperty } from '../../types/clientArea';
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const [favorites, setFavorites] = useState<any[]>([]);
|
||||
const [favorites, setFavorites] = useState<SavedProperty[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { favoriteIds, isLoading: favoritesLoading } = useFavorites();
|
||||
|
||||
useEffect(() => {
|
||||
getFavorites()
|
||||
|
|
@ -14,13 +17,38 @@ export default function FavoritesPage() {
|
|||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
const mappedEntries: FavoriteCardEntry[] = favorites
|
||||
.map((item) => {
|
||||
const prop = item.property as (SavedProperty['property'] & {
|
||||
listing_type?: 'venda' | 'aluguel';
|
||||
bedrooms?: number;
|
||||
area_m2?: number;
|
||||
}) | null;
|
||||
|
||||
if (!item.property_id || !prop) return null;
|
||||
|
||||
return {
|
||||
id: item.property_id,
|
||||
slug: prop.slug ?? '',
|
||||
title: prop.title ?? 'Imóvel',
|
||||
price: prop.price ?? '',
|
||||
type: prop.listing_type === 'aluguel' ? 'aluguel' : 'venda',
|
||||
photo: prop.cover_photo_url,
|
||||
city: [prop.neighborhood, prop.city].filter(Boolean).join(', ') || null,
|
||||
bedrooms: Number(prop.bedrooms ?? 0),
|
||||
area_m2: Number(prop.area_m2 ?? 0),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is FavoriteCardEntry => !!entry)
|
||||
.filter(entry => favoriteIds.has(entry.id));
|
||||
|
||||
if (loading || favoritesLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="h-7 w-40 animate-pulse rounded-md bg-surface mb-6" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-borderSubtle bg-panel h-48 animate-pulse" />
|
||||
<div key={i} className="rounded-xl border border-borderSubtle bg-panel h-64 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -28,37 +56,35 @@ export default function FavoritesPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl">
|
||||
<h1 className="text-xl font-semibold text-textPrimary mb-6">Meus Favoritos</h1>
|
||||
<div className="mx-auto max-w-4xl px-4 pt-6 pb-10">
|
||||
<div className="mb-8 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-textPrimary">Meus Favoritos</h1>
|
||||
<p className="text-sm text-textTertiary mt-1">
|
||||
{mappedEntries.length} {mappedEntries.length === 1 ? 'imóvel salvo' : 'imóveis salvos'} na sua conta
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition shrink-0">
|
||||
← Voltar à listagem
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{favorites.length === 0 ? (
|
||||
<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center">
|
||||
<p className="text-textTertiary mb-4">Nenhum favorito ainda</p>
|
||||
<Link to="/imoveis" className="text-sm text-accent hover:text-accentHover transition">
|
||||
Explorar imóveis →
|
||||
</Link>
|
||||
<div className="mb-6 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-textPrimary">Favoritos sincronizados</p>
|
||||
<p className="text-xs text-textTertiary mt-0.5">
|
||||
Seus favoritos ficam salvos na conta e disponíveis em qualquer dispositivo.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{favorites.map((item: any) => {
|
||||
const prop = item.property || item;
|
||||
const propertyId = item.property_id || prop?.id;
|
||||
return (
|
||||
<div key={item.id || propertyId} className="relative rounded-xl border border-borderSubtle bg-panel p-4 hover:border-borderStandard transition">
|
||||
{propertyId && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<HeartButton propertyId={propertyId} />
|
||||
</div>
|
||||
)}
|
||||
<Link to={prop?.slug ? `/imoveis/${prop.slug}` : '#'} className="block">
|
||||
<p className="text-sm font-medium text-textPrimary pr-8 line-clamp-2">{prop?.title || 'Imóvel'}</p>
|
||||
<p className="mt-1 text-xs text-textTertiary">Ver detalhes →</p>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to="/area-do-cliente/conta"
|
||||
className="rounded-lg border border-borderSubtle bg-surface px-4 py-2 text-xs font-semibold text-textPrimary hover:bg-panel transition shrink-0"
|
||||
>
|
||||
Ver minha conta
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FavoritesCardsGrid entries={mappedEntries} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
153
frontend/src/pages/client/ProfilePage.tsx
Normal file
153
frontend/src/pages/client/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { changePassword, updateProfile } from '../../services/clientArea';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
// — Form de perfil —
|
||||
const [name, setName] = useState(user?.name ?? '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [nameSaving, setNameSaving] = useState(false);
|
||||
const [nameSuccess, setNameSuccess] = useState(false);
|
||||
|
||||
// — Form de senha —
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
async function handleSaveName(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setNameError('');
|
||||
setNameSuccess(false);
|
||||
if (!name.trim()) {
|
||||
setNameError('O nome não pode ser vazio.');
|
||||
return;
|
||||
}
|
||||
setNameSaving(true);
|
||||
try {
|
||||
const updated = await updateProfile({ name: name.trim() });
|
||||
updateUser({ name: updated.name });
|
||||
setNameSuccess(true);
|
||||
} catch {
|
||||
setNameError('Erro ao salvar. Tente novamente.');
|
||||
} finally {
|
||||
setNameSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
setPasswordSuccess(false);
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError('A nova senha deve ter pelo menos 8 caracteres.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('As senhas não coincidem.');
|
||||
return;
|
||||
}
|
||||
setPasswordSaving(true);
|
||||
try {
|
||||
await changePassword({ current_password: currentPassword, new_password: newPassword });
|
||||
setPasswordSuccess(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'Erro ao alterar senha.';
|
||||
setPasswordError(msg);
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-lg space-y-8">
|
||||
<h1 className="text-xl font-semibold text-textPrimary">Minha conta</h1>
|
||||
|
||||
{/* Formulário: dados pessoais */}
|
||||
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-textPrimary">Dados pessoais</h2>
|
||||
<form onSubmit={handleSaveName} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Nome</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">E-mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email ?? ''}
|
||||
readOnly
|
||||
className="w-full rounded-lg border border-borderSubtle bg-surface px-3 py-2 text-sm text-textTertiary cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
{nameError && <p className="text-xs text-red-400">{nameError}</p>}
|
||||
{nameSuccess && <p className="text-xs text-green-400">Nome atualizado com sucesso!</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={nameSaving}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accentHover disabled:opacity-50 transition"
|
||||
>
|
||||
{nameSaving ? 'Salvando…' : 'Salvar alterações'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Formulário: trocar senha */}
|
||||
<section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-textPrimary">Alterar senha</h2>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Senha atual</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Nova senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-textSecondary mb-1">Confirmar nova senha</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
{passwordError && <p className="text-xs text-red-400">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="text-xs text-green-400">Senha alterada com sucesso!</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordSaving}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accentHover disabled:opacity-50 transition"
|
||||
>
|
||||
{passwordSaving ? 'Salvando…' : 'Alterar senha'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getVisits } from '../../services/clientArea';
|
||||
import { cancelVisit, getVisits } from '../../services/clientArea';
|
||||
import type { VisitRequest } from '../../types/clientArea';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
|
|
@ -13,6 +13,8 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
|||
export default function VisitsPage() {
|
||||
const [visits, setVisits] = useState<VisitRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const [cancelError, setCancelError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getVisits()
|
||||
|
|
@ -21,6 +23,24 @@ export default function VisitsPage() {
|
|||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleCancel(visitId: string) {
|
||||
if (!window.confirm('Confirmar cancelamento desta visita?')) return;
|
||||
setCancelling(visitId);
|
||||
setCancelError(null);
|
||||
setVisits(prev =>
|
||||
prev.map(v => (v.id === visitId ? { ...v, status: 'cancelled' as const } : v))
|
||||
);
|
||||
try {
|
||||
const updated = await cancelVisit(visitId);
|
||||
setVisits(prev => prev.map(v => (v.id === visitId ? updated : v)));
|
||||
} catch {
|
||||
setCancelError('Não foi possível cancelar. Tente novamente.');
|
||||
getVisits().then(setVisits).catch(() => {});
|
||||
} finally {
|
||||
setCancelling(null);
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null) => {
|
||||
if (!d) return '—';
|
||||
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(d));
|
||||
|
|
@ -79,10 +99,22 @@ export default function VisitsPage() {
|
|||
<span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-xs font-medium ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{visit.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(visit.id)}
|
||||
disabled={cancelling === visit.id}
|
||||
className="mt-3 rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{cancelling === visit.id ? 'Cancelando…' : 'Cancelar visita'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{cancelError && (
|
||||
<p className="mt-2 text-xs text-red-400">{cancelError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Boleto, SavedProperty, VisitRequest } from '../types/clientArea';
|
||||
import type { Boleto, ChangePasswordPayload, SavedProperty, UpdateProfilePayload, UpdateProfileResponse, VisitRequest } from '../types/clientArea';
|
||||
import api from './api';
|
||||
|
||||
export async function getFavorites(): Promise<SavedProperty[]> {
|
||||
|
|
@ -23,3 +23,18 @@ export async function getBoletos(): Promise<Boleto[]> {
|
|||
const response = await api.get<Boleto[]>('/me/boletos');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateProfile(data: UpdateProfilePayload): Promise<UpdateProfileResponse> {
|
||||
const response = await api.patch<UpdateProfileResponse>('/me/profile', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function changePassword(data: ChangePasswordPayload): Promise<void> {
|
||||
await api.patch('/me/password', data);
|
||||
}
|
||||
|
||||
export async function cancelVisit(visitId: string): Promise<VisitRequest> {
|
||||
const response = await api.patch<VisitRequest>(`/me/visits/${visitId}/cancel`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
20
frontend/src/services/contactConfig.ts
Normal file
20
frontend/src/services/contactConfig.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { api } from './api'
|
||||
|
||||
export interface ContactConfig {
|
||||
address_street: string | null
|
||||
address_neighborhood_city: string | null
|
||||
address_zip: string | null
|
||||
phone: string | null
|
||||
email: string | null
|
||||
business_hours: string | null
|
||||
}
|
||||
|
||||
export async function getContactConfig(): Promise<ContactConfig> {
|
||||
const res = await api.get<ContactConfig>('/contact-config')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateContactConfig(data: ContactConfig): Promise<ContactConfig> {
|
||||
const res = await api.put<ContactConfig>('/admin/contact-config', data)
|
||||
return res.data
|
||||
}
|
||||
|
|
@ -1,7 +1,32 @@
|
|||
import type { HomepageConfig } from '../types/homepage'
|
||||
import type { HomepageConfig, HomepageHeroImagesPayload } from '../types/homepage'
|
||||
import { api } from './api'
|
||||
|
||||
interface UploadPhotoResponse {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export async function getHomepageConfig(): Promise<HomepageConfig> {
|
||||
const response = await api.get<HomepageConfig>('/homepage-config')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateHomepageHeroImages(
|
||||
payload: HomepageHeroImagesPayload,
|
||||
): Promise<HomepageConfig> {
|
||||
const response = await api.put<HomepageConfig>('/admin/homepage-config', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function uploadHomepageHeroImage(file: File): Promise<UploadPhotoResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await api.post<UploadPhotoResponse>('/admin/upload/photo', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
|
|
|||
14
frontend/src/services/jobs.ts
Normal file
14
frontend/src/services/jobs.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { api } from './api'
|
||||
|
||||
export interface JobApplicationPayload {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
role_interest: string
|
||||
message: string
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
export async function submitJobApplication(data: JobApplicationPayload): Promise<void> {
|
||||
await api.post('/jobs/apply', data)
|
||||
}
|
||||
|
|
@ -79,3 +79,10 @@ export async function submitContactForm(
|
|||
return response.data
|
||||
}
|
||||
|
||||
export async function submitGeneralContact(
|
||||
data: ContactFormData
|
||||
): Promise<{ id: number; message: string }> {
|
||||
const response = await api.post<{ id: number; message: string }>('/contact', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,13 +18,35 @@ export interface Boleto {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PropertyCard {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
price: string | null;
|
||||
city: string | null;
|
||||
neighborhood: string | null;
|
||||
cover_photo_url: string | null;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
id: string;
|
||||
property_id: string | null;
|
||||
property: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
property: PropertyCard | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfilePayload {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,4 +5,12 @@ export interface HomepageConfig {
|
|||
hero_cta_url: string
|
||||
featured_properties_limit: number
|
||||
hero_image_url?: string | null
|
||||
hero_image_light_url?: string | null
|
||||
hero_image_dark_url?: string | null
|
||||
}
|
||||
|
||||
export interface HomepageHeroImagesPayload {
|
||||
hero_image_url?: string | null
|
||||
hero_image_light_url?: string | null
|
||||
hero_image_dark_url?: string | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,4 +50,6 @@ export interface ContactFormData {
|
|||
email: string
|
||||
phone: string
|
||||
message: string
|
||||
source?: string
|
||||
source_detail?: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue