# Agent card

Realtor contact card with initials avatar, rating, license, languages and call, email and tour actions.

- Type: component
- Category: Cards
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/agent-card.json
```

## Usage

```tsx
import { AgentCard } from "@/components/facade/agent-card";

export default function Page() {
  return <AgentCard />;
}
```

## Source

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Calendar, Mail, Phone, Star } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AgentCardProps {
  name?: string;
  title?: string;
  brokerage?: string;
  license?: string;
  /** Optional headshot URL. Falls back to an initials avatar. */
  photoSrc?: string;
  rating?: number;
  reviewCount?: number;
  languages?: string[];
  phone?: string;
  email?: string;
  /** Link or handler for the schedule tour action. */
  scheduleHref?: string;
  onSchedule?: () => void;
  /** Optional stats shown under the header, e.g. homes sold. */
  stats?: Array<{ label: string; value: string }>;
  /** Show the online status dot on the avatar. */
  available?: boolean;
  className?: string;
}

function initialsOf(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((p) => p[0]?.toUpperCase() ?? "")
    .join("");
}

function Rating({ value, count }: { value: number; count?: number }) {
  const rounded = Math.round(value * 2) / 2;
  return (
    <div className="flex items-center gap-1.5" role="img" aria-label={`Rated ${value.toFixed(1)} out of 5${count ? ` from ${count} reviews` : ""}`}>
      <div className="flex" aria-hidden="true">
        {Array.from({ length: 5 }).map((_, i) => {
          const fill = Math.max(0, Math.min(1, rounded - i));
          return (
            <span key={i} className="relative size-3.5">
              <Star className="absolute inset-0 size-3.5 text-muted-foreground/30" fill="currentColor" strokeWidth={0} />
              <span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
                <Star className="size-3.5 text-warning" fill="currentColor" strokeWidth={0} />
              </span>
            </span>
          );
        })}
      </div>
      <span className="text-sm font-medium tabular-nums text-foreground">{value.toFixed(1)}</span>
      {count !== undefined && <span className="text-sm text-muted-foreground">({count})</span>}
    </div>
  );
}

export function AgentCard({
  name = "Danielle Ortiz",
  title = "Realtor",
  brokerage = "Summit Peak Realty",
  license = "CO FA.100082417",
  photoSrc,
  rating = 4.9,
  reviewCount = 128,
  languages = ["English", "Spanish"],
  phone = "(303) 555-0148",
  email = "danielle@summitpeakrealty.com",
  scheduleHref = "#schedule",
  onSchedule,
  stats,
  available = true,
  className,
}: AgentCardProps) {
  const reduce = useReducedMotion();
  const tel = `tel:${phone.replace(/[^0-9+]/g, "")}`;

  const btnBase =
    "inline-flex h-10 items-center justify-center gap-2 rounded-xl px-3 text-sm font-medium transition-[background-color,border-color,color,opacity] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card";

  return (
    <motion.article
      whileHover={reduce ? undefined : { y: -2 }}
      transition={{ type: "spring", stiffness: 400, damping: 34 }}
      className={cn(
        "group w-full max-w-sm rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm transition-[box-shadow,border-color] duration-300 hover:border-foreground/15 hover:shadow-lg hover:shadow-foreground/5",
        className,
      )}
    >
      <header className="flex items-start gap-4">
        <div className="relative shrink-0">
          <div className="rounded-full bg-gradient-to-br from-brand to-brand/30 p-[2px] transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:rotate-6">
            <div className="rounded-full bg-card p-[3px]">
              {photoSrc ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img src={photoSrc} alt={name} className="size-16 rounded-full object-cover" />
              ) : (
                <div
                  className="grid size-16 place-items-center rounded-full bg-accent font-display text-xl font-semibold tracking-tight text-accent-foreground"
                  aria-hidden="true"
                >
                  {initialsOf(name)}
                </div>
              )}
            </div>
          </div>
          {available && (
            <span className="absolute bottom-0.5 right-0.5 size-3.5 rounded-full border-2 border-card bg-success" aria-label="Available now" role="img" />
          )}
        </div>
        <div className="min-w-0 flex-1 pt-0.5">
          <h3 className="truncate font-display text-lg font-semibold leading-tight tracking-tight">{name}</h3>
          <p className="truncate text-sm text-muted-foreground">
            {title} · {brokerage}
          </p>
          <div className="mt-1.5">
            <Rating value={rating} count={reviewCount} />
          </div>
        </div>
      </header>

      {stats && stats.length > 0 && (
        <dl className="mt-4 grid grid-cols-3 divide-x divide-border rounded-xl border border-border bg-muted/40">
          {stats.slice(0, 3).map((s) => (
            <div key={s.label} className="flex flex-col-reverse px-2 py-2.5 text-center">
              <dt className="text-[11px] text-muted-foreground">{s.label}</dt>
              <dd className="font-display text-base font-semibold tracking-tight tabular-nums">{s.value}</dd>
            </div>
          ))}
        </dl>
      )}

      <div className="mt-4 flex flex-wrap items-center gap-1.5">
        {languages.map((l) => (
          <span key={l} className="rounded-full border border-border bg-background px-2.5 py-0.5 text-xs text-muted-foreground">
            {l}
          </span>
        ))}
        <span className="ml-auto font-mono text-[11px] text-muted-foreground">Lic. {license}</span>
      </div>

      <div className="mt-5 grid grid-cols-2 gap-2">
        <a href={tel} className={cn(btnBase, "border border-border bg-background text-foreground hover:bg-muted")}>
          <Phone className="size-4" aria-hidden="true" />
          Call
        </a>
        <a href={`mailto:${email}`} className={cn(btnBase, "border border-border bg-background text-foreground hover:bg-muted")}>
          <Mail className="size-4" aria-hidden="true" />
          Email
        </a>
        {onSchedule ? (
          <button type="button" onClick={onSchedule} className={cn(btnBase, "col-span-2 bg-primary text-primary-foreground hover:opacity-90")}>
            <Calendar className="size-4" aria-hidden="true" />
            Schedule a tour
          </button>
        ) : (
          <a href={scheduleHref} className={cn(btnBase, "col-span-2 bg-primary text-primary-foreground hover:opacity-90")}>
            <Calendar className="size-4" aria-hidden="true" />
            Schedule a tour
          </a>
        )}
      </div>
    </motion.article>
  );
}

export default AgentCard;
```
