Listing Card
Compact property card with cover image, price, location and key features. Drop-in card for grids and carousels.
Instalar
npx shadcn@latest add https://sdk.garagem.site/r/listing-card.jsonDependências npm
@garagem-ai/site-sdklucide-react
Componentes shadcn (auto-instalados)
badgecard
Arquivos instalados
- components/garagem/listing-card.tsx
Código fonte
listing-card/listing-card.tsx
import type { Listing as SdkListing } from "@garagem-ai/site-sdk";
import Image from "next/image";
import Link from "next/link";
import { Bath, BedDouble, Car, Ruler } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
/**
* Curated subset of the SDK's `Listing` that `<ListingCard>` actually
* consumes. Consumers can pass either a full `Listing` from the SDK or
* a minimal object that satisfies this shape.
*/
export type Listing = Pick<
SdkListing,
"id" | "title" | "businessType" | "price" | "priceVisibility" | "coverImage"
> & {
property: {
type?: string;
bedrooms?: number;
bathrooms?: number;
parkingSpaces?: number;
usableArea?: number;
totalArea?: number;
};
location: {
neighborhood?: string;
city?: string;
state?: string;
};
};
export interface ListingCardProps {
listing: Listing;
/** Where the card links to. Defaults to `/imoveis/<id>`. */
href?: string;
/** Pass-through className for the outer `<Card>`. */
className?: string;
/** Tailwind aspect-ratio class for the cover image. Defaults to `aspect-[4/3]`. */
imageAspectRatio?: string;
/** Currency code (Intl.NumberFormat). Defaults to `BRL`. */
currency?: string;
/** BCP-47 locale tag. Defaults to `pt-BR`. */
locale?: string;
}
function formatPrice(
amount: number | null,
currency: string,
locale: string
): string {
if (amount == null) return "Sob consulta";
try {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
} catch {
return `${currency} ${amount}`;
}
}
function formatLocation(loc: Listing["location"]): string {
const parts = [loc.neighborhood, loc.city, loc.state].filter(Boolean);
return parts.join(", ");
}
export function ListingCard({
listing,
href,
className,
imageAspectRatio = "aspect-[4/3]",
currency = "BRL",
locale = "pt-BR",
}: ListingCardProps) {
const target = href ?? `/imoveis/${listing.id}`;
const isPrivatePrice = listing.priceVisibility === "private";
const priceLabel = isPrivatePrice
? "Sob consulta"
: formatPrice(listing.price, currency, locale);
const businessLabel = listing.businessType === "rental" ? "Aluguel" : "Venda";
return (
<Card
className={cn(
"group overflow-hidden p-0 gap-0 transition-shadow hover:shadow-md",
className
)}
>
<Link href={target} className="block">
<div
className={cn(
"relative w-full overflow-hidden bg-muted",
imageAspectRatio
)}
>
{listing.coverImage?.src ? (
<Image
src={listing.coverImage.src}
alt={listing.coverImage.alt ?? listing.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
Sem foto
</div>
)}
<Badge className="absolute left-3 top-3" variant="secondary">
{businessLabel}
</Badge>
</div>
<CardContent className="p-4">
<div className="flex items-baseline justify-between gap-2">
<p className="text-lg font-semibold tracking-tight">{priceLabel}</p>
{listing.businessType === "rental" && !isPrivatePrice && (
<span className="text-xs text-muted-foreground">/mês</span>
)}
</div>
<h3 className="mt-1 line-clamp-1 text-sm font-medium">
{listing.title}
</h3>
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{formatLocation(listing.location) || listing.property.type || ""}
</p>
<ListingFeatures property={listing.property} />
</CardContent>
</Link>
</Card>
);
}
function ListingFeatures({ property }: { property: Listing["property"] }) {
const items: Array<{ icon: typeof BedDouble; label: string }> = [];
if (property.bedrooms != null) {
items.push({ icon: BedDouble, label: `${property.bedrooms}` });
}
if (property.bathrooms != null) {
items.push({ icon: Bath, label: `${property.bathrooms}` });
}
if (property.parkingSpaces != null) {
items.push({ icon: Car, label: `${property.parkingSpaces}` });
}
const area = property.usableArea ?? property.totalArea;
if (area != null) {
items.push({ icon: Ruler, label: `${area}m²` });
}
if (items.length === 0) return null;
return (
<ul className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{items.map(({ icon: Icon, label }, i) => (
<li key={i} className="flex items-center gap-1">
<Icon className="size-3.5" aria-hidden="true" />
<span>{label}</span>
</li>
))}
</ul>
);
}