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

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

View file

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