Todos os blocos

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.json
Abrir no v0.dev

Dependê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>
  );
}