Concorde
BETA

OfferCard

Card de oferta de vehículo con imagen, precio y botón de favorito. En la variante live la fila de precio incluye un icono de gema fijo integrado (no se controla por props).

Audi Q3

Audi Q3

2026

US$ 9,999
Audi Q3

Audi Q3

2026

<OfferCard variant="live" name="Audi Q3" year="2026" price="US$ 9,999" imageSrc="/img/q3.jpg" />
<OfferCard variant="negotiable" name="Audi Q3" year="2026" imageSrc="/img/q3.jpg" />

Instalación

$ npx github:AaronCoorahua/ConcordeV2#cli add offercard

Uso

import OfferCard from "@/src/components/OfferCard";

<OfferCard
  variant="live"
  name="Ford Bronco Sport"
  year="2024"
  price="US$ 9,999"
  imageSrc="/img/bronco.jpg"
  onClick={() => router.push("/subasta/123")}
/>

Ejemplos

Live

Barra naranja con gema, precio y like.

Audi Q3

Audi Q3

2026

US$ 9,999
<OfferCard variant="live" name="Audi Q3" year="2026" price="US$ 9,999" imageSrc="/img/q3.jpg" />

Negotiable

Barra teal, solo like (sin precio).

Audi Q3

Audi Q3

2026

<OfferCard variant="negotiable" name="Audi Q3" year="2026" imageSrc="/img/q3.jpg" />

Elevated

Sombra más marcada para destacar la card.

Audi Q3

Audi Q3

2026

US$ 9,999
<OfferCard variant="live" name="Audi Q3" year="2026" price="US$ 9,999" imageSrc="/img/q3.jpg" elevated />

Sin imagen

Slot de imagen vacío como placeholder.

Audi Q3

2026

US$ 9,999
<OfferCard variant="live" name="Audi Q3" year="2026" price="US$ 9,999" />

API

PropTipoDefault
variant
Barra inferior y fila de precio (live) vs solo like (negotiable).
"live" | "negotiable"
name
Nombre del vehículo (truncado con ellipsis).
string
year
Año de la oferta.
string | number
price
Precio formateado. Solo se muestra en variant="live".
string
imageSrc
URL de la imagen (object-fit: cover).
string
imageAlt
Alt text de la imagen.
stringname
badge
Pill de estado renderizado sobre la imagen.
JSX.Element
liked
Estado del like (controlado).
boolean
onLikeChange
Callback al alternar el like.
(liked: boolean) => void
onClick
Click en la card (añade role=button y Enter/Space).
() => void
"aria-label"
Etiqueta accesible de la card.
stringname
interactive
false → showcase estático, sin hover/press.
booleantrue
elevated
Sombra elevada.
booleanfalse
classNamestring""

Código del componente

Ver OfferCard.tsx completo · self-contained · zero deps
OfferCard.tsx
"use client";

/**
 * OfferCard — Generado por Concorde
 * Fuente: https://voyager-ds.vercel.app/preview/components/button-primary
 * Generado: 2026-05-28
 * EDITAR LIBREMENTE después de generar
 *
 * Variantes: live (orange bar, pprice+price+like) ·
 *            negotiable (teal bar, solo like)
 */

import { useState, useCallback, useId } from "react";
import type { JSX } from "react";

// ─── Types ────────────────────────────────────────────────────────────────────

export type OfferCardVariant = "live" | "negotiable";

export interface OfferCardProps {
  /** Variante — determina barra inferior y fila de precio */
  variant: OfferCardVariant;
  /** Tamaño. "short" = card compacta 134×170 (solo imagen + título/año + barra,
   *  con el precio y el like como overlays sobre la imagen). Default "full". */
  size?: "full" | "short";
  /** Nombre del vehículo */
  name: string;
  /** Año */
  year: string | number;
  /**
   * Precio formateado ("US$ 9,999").
   * Si no se pasa, la fila de precio no se renderiza.
   * En negotiable solo aparece el like.
   */
  price?: string;
  /** URL imagen */
  imageSrc?: string;
  /** Alt text de imagen */
  imageAlt?: string;
  /** Badge de estado (pill BadgeStatus) — renderizado en img-badge slot */
  badge?: JSX.Element;
  /** Estado like controlado */
  liked?: boolean;
  /** Callback al alternar like */
  onLikeChange?: (liked: boolean) => void;
  /** Click en la card */
  onClick?: () => void;
  /** Aria label */
  "aria-label"?: string;
  /** Si false: showcase estático, sin hover/press. Default true */
  interactive?: boolean;
  /** Sombra elevada de Figma (X0 Y0 · Blur16 · Spread4 · #000000 10%). Default false */
  elevated?: boolean;
  className?: string;
}

// ─── Self-contained CSS ───────────────────────────────────────────────────────

const STYLE_ID = "concorde-offercard-styles";

const OFFERCARD_STYLES = `
@keyframes offercard-heart-pop {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.4); }
  65%  { transform: scale(0.85); }
  85%  { transform: scale(1.1); }
  100% { transform: scale(1); }
}
@keyframes offercard-pulse {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0.5; }
}

/* ── Card base — ancho y alto fijo, igual en ambas variantes ── */
.pcard {
  width: 170px;
  height: 232px;
  box-sizing: border-box;
  background: #ffffff;
  border-radius: 8px;
  box-shadow:
    rgba(0, 0, 0, 0.07) 0px 0px 16px 4px;
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
  position: relative;
  overflow: hidden;
  transition:
    transform 150ms cubic-bezier(0.3, 0, 0, 1),
    box-shadow 150ms cubic-bezier(0.3, 0, 0, 1);
  cursor: pointer;
}

.pcard:hover:not(.pcard--disabled):not(.pcard--skeleton) {
  transform: translateY(-3px);
  box-shadow: oklch(0.22 0.18 285 / 0.12) 0px 12px 20px;
}

/* ── Image ── */
.pcard__img {
  width: 100%;
  height: 112px;
  background: oklch(0.93 0.006 220);
  flex-shrink: 0;
  overflow: hidden;
  border-radius: 8px 8px 0 0;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pcard__img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.pcard__img-badge {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
}

/* ── Body ── */
.pcard__body {
  display: flex;
  flex-direction: column;
  padding: 12px;
  height: 112px;
  flex-shrink: 0;
  box-sizing: border-box;
}

.pcard__meta {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.pcard__name {
  font-family: var(--vmc-font-display, 'Plus Jakarta Sans', sans-serif);
  font-size: 16px;
  font-weight: 700;
  line-height: 20px;
  color: #4c1ebc;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
}

.pcard__year {
  font-family: var(--vmc-font-display, 'Plus Jakarta Sans', sans-serif);
  font-size: 11px;
  font-weight: 500;
  line-height: 16px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #191c1c;
  margin: 0;
}

/* Price row — pprice + price text + like */
.pcard__price-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 10px;
  flex-shrink: 0;
}

.pcard__price-left {
  display: flex;
  align-items: center;
  gap: 6px;
}

.pcard__price-text {
  font-family: var(--vmc-font-mono, 'Roboto Mono', monospace);
  font-size: 13px;
  font-weight: 700;
  line-height: 20px;
  font-variant-numeric: tabular-nums;
  color: #4c1ebc;
  white-space: nowrap;
}

/* Like row — solo like (negotiable) */
.pcard__like-row {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  margin-top: 10px;
  flex-shrink: 0;
}

/* ── Variants — bottom bars ── */
.pcard--live {
  position: relative;
}
.pcard--live::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 8px;
  background: linear-gradient(90deg, #ff9639 0%, #ef852e 50%, #be3d00 100%);
  border-radius: 0 0 var(--vmc-radius-sm, 4px) var(--vmc-radius-sm, 4px);
}

.pcard--negotiable {
  position: relative;
}
.pcard--negotiable::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 8px;
  background: linear-gradient(90deg, #00edee 0%, #00d2d3 50%, #009597 100%);
  border-radius: 0 0 var(--vmc-radius-sm, 4px) var(--vmc-radius-sm, 4px);
}


/* ── Price gem (auction diamond, paths exactos de Figma) ── */
.pcard-pprice {
  width: 30px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

/* ── Mini like button (self-contained) ── */
.pcard-like {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 9999px;
  border: 1.44px solid transparent;
  background-image:
    linear-gradient(#ffffff, #ffffff),
    linear-gradient(135deg, #e8ddff 0%, #ffffff 40%, #cfbaff 75%, #ae8eff 100%);
  background-origin: padding-box, border-box;
  background-clip: padding-box, border-box;
  cursor: pointer;
  padding: 2px;
  position: relative;
  overflow: hidden;
  box-shadow: rgba(132, 96, 229, 0.14) 0px 2px 8px;
  transition:
    transform 0.15s cubic-bezier(0.25, 0.8, 0.25, 1),
    box-shadow 0.2s;
  flex-shrink: 0;
  outline: none;
}
/* Glass highlight (white 0.55 → transparent) */
.pcard-like::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 9999px;
  background: linear-gradient(rgba(255, 255, 255, 0.55) 0%, transparent 50%);
  pointer-events: none;
  z-index: 1;
}
.pcard-like svg { position: relative; z-index: 2; }
.pcard-like:hover {
  transform: scale(1.1) translateY(-1px);
  box-shadow: rgba(32, 0, 104, 0.2) 0px 6px 14px;
}
.pcard-like:active { transform: scale(0.92); }
.pcard-like--active {
  background-image:
    linear-gradient(135deg, #8460e5 0%, #3b1782 100%),
    linear-gradient(135deg, #fbc47d 0%, #fbc47d 100%);
  box-shadow: rgba(132, 96, 229, 0.35) 0px 2px 8px, inset 0px 1px 0px rgba(255,255,255,0.22);
}
.pcard-like--pop svg {
  animation: offercard-heart-pop 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) 1;
}
.pcard-like:focus-visible {
  outline: 2px solid oklch(0.62 0.20 285);
  outline-offset: 2px;
}

/* ── Sombra elevada — token "card" de Figma (X0 Y0 · Blur16 · Spread4 · #000000 10%) ── */
.pcard--elevated { box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 16px 4px; }
.pcard--static.pcard--elevated:hover { box-shadow: rgba(0, 0, 0, 0.1) 0px 0px 16px 4px; }

/* ── Static showcase (sin hover/press) ── */
.pcard--static, .pcard--static .pcard-like { transition: none; cursor: default; }
.pcard--static:hover { transform: none; box-shadow: rgba(0, 0, 0, 0.07) 0px 0px 16px 4px; }
.pcard--static .pcard-like { pointer-events: none; }
.pcard--static .pcard-like:hover { transform: none; box-shadow: rgba(132, 96, 229, 0.14) 0px 2px 8px; }
.pcard--static .pcard-like:active { transform: none; }

/* ── Short variant (134×170) — imagen 134×112 + título/año 134×50 + barra 134×8.
   Precio (99×24) y like (24×24) van como overlays SOBRE la imagen. ── */
.pcard--short { width: 134px; height: 170px; }
.pcard--short .pcard__img { height: 112px; }
.pcard__body--short {
  height: 50px;
  box-sizing: border-box;
  padding: 8px 10px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1px;
}
.pcard--short .pcard__name { font-size: 15px; line-height: 18px; }
.pcard--short .pcard__year { font-size: 10px; line-height: 14px; }

/* Precio como badge (99×24) — arriba a la izquierda de la imagen */
.pcard__price-badge {
  position: absolute;
  top: 8px;
  left: 7px;
  height: 24px;
  box-sizing: border-box;
  padding: 0 10px 0 3px;
  border-radius: 12px;
  display: inline-flex;
  align-items: center;
  gap: 3px;
  border: 1px solid transparent;
  background-image:
    linear-gradient(135deg, #8460e5 0%, #3b1782 100%),
    linear-gradient(135deg, #8776ff 0%, rgba(255,255,255,0.4) 38%, #532bc7 68%, #8776ff 100%);
  background-origin: padding-box, border-box;
  background-clip: padding-box, border-box;
  box-shadow: rgba(32, 0, 104, 0.5) 0px 2px 6px;
  z-index: 2;
}
.pcard__price-badge::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 12px;
  background: linear-gradient(180deg, rgba(255,255,255,0.1) 0%, transparent 100%);
  pointer-events: none;
}
.pcard__price-badge-gem { width: 20px; height: 22px; display: inline-flex; position: relative; z-index: 1; flex-shrink: 0; }
.pcard__price-badge-text {
  position: relative;
  z-index: 1;
  font-family: var(--vmc-font-mono, 'Roboto Mono', monospace);
  font-size: 13px;
  font-weight: 700;
  color: #ffffff;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* Like (24×24) — abajo a la derecha de la imagen */
.pcard__img-like { position: absolute; bottom: 8px; right: 9px; z-index: 2; }
.pcard--short .pcard__img-like .pcard-like { width: 22px; height: 22px; }
.pcard--short .pcard__img-like .pcard-like svg { width: 12px; height: 12px; }

/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
  .pcard, .pcard-like { transition: none; }
  .pcard-like--pop svg { animation: none; }
}
`;

const HEART_PATH  = "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z";

let _stylesInjected = false;

// ─── Which variants show price row vs like row ────────────────────────────────

const SHOW_PRICE: Record<OfferCardVariant, boolean> = {
  live:       true,   // pprice + price + like
  negotiable: false,  // solo like
};

// ─── Component ────────────────────────────────────────────────────────────────

export default function OfferCard({
  variant,
  size = "full",
  name,
  year,
  price,
  imageSrc,
  imageAlt,
  badge,
  liked: controlledLiked,
  onLikeChange,
  onClick,
  "aria-label": ariaLabel,
  interactive = true,
  elevated = false,
  className = "",
}: OfferCardProps): JSX.Element {
  if (typeof document !== "undefined" && !_stylesInjected) {
    if (!document.getElementById(STYLE_ID)) {
      const el = document.createElement("style");
      el.id = STYLE_ID;
      el.textContent = OFFERCARD_STYLES;
      document.head.appendChild(el);
    }
    _stylesInjected = true;
  }

  const uid = useId().replace(/:/g, "-");

  const isControlled = controlledLiked !== undefined;
  const [internalLiked, setInternalLiked] = useState(false);
  const isLiked = isControlled ? controlledLiked : internalLiked;
  const [popping, setPopping] = useState(false);

  const handleLike = useCallback((e: React.MouseEvent) => {
    e.stopPropagation();
    const next = !isLiked;
    if (!isControlled) setInternalLiked(next);
    onLikeChange?.(next);
    if (next) {
      setPopping(true);
      setTimeout(() => { setPopping(false); }, 420);
    }
  }, [isLiked, isControlled, onLikeChange]);

  const showPriceRow = SHOW_PRICE[variant] && (price != null);
  const showLikeRow  = !showPriceRow;

  const cardClasses = [
    "pcard",
    `pcard--${variant}`,
    size === "short" ? "pcard--short" : "",
    interactive ? "" : "pcard--static",
    elevated ? "pcard--elevated" : "",
    className,
  ].filter(Boolean).join(" ");

  const likeClasses = [
    "pcard-like",
    isLiked ? "pcard-like--active" : "",
    popping ? "pcard-like--pop"    : "",
  ].filter(Boolean).join(" ");

  const svgFill   = isLiked ? "rgba(255,255,255,0.92)" : "#ffffff";
  const svgStroke = isLiked ? "none" : "#5a35c2";

  const LikeBtn = (
    <button
      type="button"
      className={likeClasses}
      onClick={handleLike}
      aria-label={isLiked ? "Quitar de favoritos" : "Agregar a favoritos"}
      aria-pressed={isLiked}
    >
      <svg
        width="15" height="15"
        viewBox="0 0 24 24"
        fill={svgFill}
        stroke={svgStroke}
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
      >
        <path d={HEART_PATH} />
      </svg>
    </button>
  );

  // Gema del precio (diamante de subasta) — reutilizada en la fila (full) y en el badge (short)
  const PriceGem = ({ w, h }: { w: number; h: number }): JSX.Element => (
    <svg width={w} height={h} viewBox="30 199 28 30" fill="none" aria-hidden="true">
      <defs>
        <linearGradient id={`${uid}-d`} gradientUnits="userSpaceOnUse" x1="32" y1="215.5" x2="55.12" y2="228.11">
          <stop stopColor="#00A7A8" /><stop offset="0.4" stopColor="#86A4E4" /><stop offset="0.75" stopColor="#4C1EBC" /><stop offset="1" stopColor="#300089" />
        </linearGradient>
        <linearGradient id={`${uid}-ds`} gradientUnits="userSpaceOnUse" x1="32" y1="215.5" x2="55.12" y2="228.11">
          <stop stopColor="#73DFDF" /><stop offset="0.28" stopColor="#ffffff" stopOpacity="0.9" /><stop offset="0.875" stopColor="#452AA2" stopOpacity="0.6" />
        </linearGradient>
        <linearGradient id={`${uid}-c`} gradientUnits="userSpaceOnUse" x1="36.01" y1="199.59" x2="51.99" y2="222.41">
          <stop stopColor="#00ABAD" /><stop offset="0.4" stopColor="#86A4E4" /><stop offset="0.75" stopColor="#4C1EBC" /><stop offset="1" stopColor="#31008A" />
        </linearGradient>
        <linearGradient id={`${uid}-cg`} gradientUnits="userSpaceOnUse" x1="44" y1="203" x2="44" y2="219">
          <stop stopColor="#ffffff" stopOpacity="0.45" /><stop offset="0.5" stopColor="#ffffff" stopOpacity="0" />
        </linearGradient>
        <linearGradient id={`${uid}-cb`} gradientUnits="userSpaceOnUse" x1="36.01" y1="199.59" x2="51.99" y2="222.41">
          <stop stopColor="#44D6D6" /><stop offset="0.207" stopColor="#E4EEFF" stopOpacity="0.5" /><stop offset="0.495" stopColor="#567CD3" /><stop offset="1" stopColor="#3D0D9E" />
        </linearGradient>
      </defs>
      <path d="M44 215.5L56 221L44 226.5L32 221L44 215.5Z" fill={`url(#${uid}-d)`} />
      <path d="M44 215.5L56 221L44 226.5L32 221L44 215.5" stroke={`url(#${uid}-ds)`} strokeWidth="2" strokeLinejoin="round" />
      <rect x="34" y="201" width="20" height="20" rx="10" fill={`url(#${uid}-c)`} />
      <rect x="36" y="203" width="16" height="16" rx="8" fill={`url(#${uid}-cg)`} />
      <rect x="35" y="202" width="18" height="18" rx="9" stroke={`url(#${uid}-cb)`} strokeWidth="2" />
      <path d="M44 206.417V215.583" stroke="#ffffff" strokeOpacity="0.92" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M46.0833 208.083H42.9583C42.1535 208.083 41.5 208.737 41.5 209.542C41.5 210.347 42.1535 211 42.9583 211H45.0417C45.8465 211 46.5 211.653 46.5 212.458C46.5 213.263 45.8465 213.917 45.0417 213.917H41.5" stroke="#ffffff" strokeOpacity="0.92" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );

  // ── Short variant: imagen con precio + like overlay, luego título/año, barra por ::after ──
  if (size === "short") {
    return (
      <>
        <style id={`${STYLE_ID}-ssr`} suppressHydrationWarning dangerouslySetInnerHTML={{ __html: OFFERCARD_STYLES }} />
        <article
          className={cardClasses}
          onClick={onClick}
          aria-label={ariaLabel ?? name}
          role={onClick ? "button" : undefined}
          tabIndex={onClick ? 0 : undefined}
        >
          <div className="pcard__img">
            {imageSrc && <img src={imageSrc} alt={imageAlt ?? name} loading="lazy" />}
            {showPriceRow && (
              <div className="pcard__price-badge">
                <span className="pcard__price-badge-gem"><PriceGem w={20} h={22} /></span>
                <span className="pcard__price-badge-text">{price}</span>
              </div>
            )}
            <div className="pcard__img-like">{LikeBtn}</div>
          </div>
          <div className="pcard__body--short">
            <h3 className="pcard__name">{name}</h3>
            <p className="pcard__year">{String(year)}</p>
          </div>
        </article>
      </>
    );
  }

  return (
    <>
      <style
        id={`${STYLE_ID}-ssr`}
        suppressHydrationWarning
        dangerouslySetInnerHTML={{ __html: OFFERCARD_STYLES }}
      />
      <article
        className={cardClasses}
        onClick={onClick}
        aria-label={ariaLabel ?? name}
        role={onClick ? "button" : undefined}
        tabIndex={onClick ? 0 : undefined}
        onKeyDown={function handleKey(e) {
          if (onClick && (e.key === "Enter" || e.key === " ")) onClick();
        }}
      >
        {/* Image */}
        <div className="pcard__img">
          {imageSrc && (
            <img src={imageSrc} alt={imageAlt ?? name} loading="lazy" />
          )}
          {badge && (
            <div className="pcard__img-badge">{badge}</div>
          )}
        </div>

        {/* Body */}
        <div className="pcard__body">
          <div className="pcard__meta">
            <h3 className="pcard__name">{name}</h3>
            <p className="pcard__year">{String(year)}</p>
          </div>

          {/* Price row — live / proxima / when price provided */}
          {showPriceRow && (
            <div className="pcard__price-row">
              <div className="pcard__price-left">
                <div className="pcard-pprice" aria-hidden="true">
                  <PriceGem w={30} h={32} />
                </div>
                <span className="pcard__price-text">{price}</span>
              </div>
              {LikeBtn}
            </div>
          )}

          {/* Like row — negotiable / expired (no price shown) */}
          {showLikeRow && (
            <div className="pcard__like-row">
              {LikeBtn}
            </div>
          )}
        </div>
      </article>
    </>
  );
}