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