Concorde
BETA

BidPosition

Tabla de posiciones de pujas; la 1ª destaca como live con trofeo.

PUESTOC.U.U.BIDS
rodrigo_8812
ana.valdez9
jp_motors7
<BidPosition positions={positions} />

En vivo (API / socket)

Las posiciones se alimentan en vivo: cuando un postor sube de puesto, la fila se desliza a su nueva posición (animación FLIP). Requiere id estable por fila. El código de abajo es el del demo (los nombres están hardcodeados; en producción vienen del socket).

PUESTOC.U.U.BIDS
rodrigo_8812
ana.valdez11
jp_motors10
lucia.q6
max_rt4
"use client";

/**
 * Demo en vivo de BidPosition — simula un feed de API/socket: cada cierto tiempo
 * un postor suma bids y la tabla se reordena, mostrando la animación de cambio de
 * posición (FLIP). En producción reemplazas el setInterval por tu suscripción real
 * y pasas el array resultante a `positions`.
 */

import { useEffect, useState } from "react";
import type { JSX } from "react";
import BidPosition, { type BidPositionItem } from "@/src/components/BidPosition";

// Nombres de ejemplo (hardcoded para el demo; en producción vienen del socket).
const INITIAL: BidPositionItem[] = [
  { id: "u1", name: "rodrigo_88", value: "12" },
  { id: "u2", name: "ana.valdez", value: "11" },
  { id: "u3", name: "jp_motors", value: "10" },
  { id: "u4", name: "lucia.q", value: "6" },
  { id: "u5", name: "max_rt", value: "4" },
];

function bump(prev: BidPositionItem[]): BidPositionItem[] {
  // Sesga la puja hacia el 2º y 3º para que peleen el 1er puesto → los cambios
  // arriba (1º ↔ 2º ↔ 3º) son frecuentes y bien visibles.
  const pool = [1, 2, 1, 2, 0, 3].filter(function inRange(i) { return i < prev.length; });
  const idx = pool[Math.floor(Math.random() * pool.length)];
  const top = Math.max(...prev.map(function val(p) { return Number(p.value); }));
  const cur = Number(prev[idx].value);
  // Si es un perseguidor, súbele lo justo para rebasar al líder; si ya es líder, +1/2.
  const inc = idx === 0 ? 1 + Math.floor(Math.random() * 2) : top - cur + 1 + Math.floor(Math.random() * 2);
  const updated = prev.map(function add(p, i) {
    return i === idx ? { ...p, value: String(cur + inc) } : p;
  });
  // Reordena por bids desc (la 1ª pasa a live + trofeo).
  return [...updated].sort(function byValue(a, b) {
    return Number(b.value) - Number(a.value);
  });
}

export default function BidPositionDemo(): JSX.Element {
  const [items, setItems] = useState<BidPositionItem[]>(INITIAL);

  useEffect(function subscribe() {
    const t = setInterval(function onMessage() {
      setItems(bump);
    }, 1000);
    return function unsubscribe() {
      clearInterval(t);
    };
  }, []);

  return (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
      <BidPosition positions={items} />
      <button
        type="button"
        onClick={function manual() { setItems(bump); }}
        style={{
          fontSize: 13,
          fontWeight: 600,
          color: "#52525b",
          background: "#fafafa",
          border: "1px solid #e4e4e7",
          borderRadius: 8,
          padding: "6px 12px",
          cursor: "pointer",
        }}
      >
        Simular puja
      </button>
    </div>
  );
}

Instalación

$ npx github:AaronCoorahua/ConcordeV2#cli add bidposition

Uso

import BidPosition, { type BidPositionItem } from "@/src/components/BidPosition";

const positions: BidPositionItem[] = [
  { name: "rodrigo_88", value: "12" },
  { name: "ana.valdez", value: "9" },
];

<BidPosition positions={positions} />

Ejemplos

Default

Cinco posiciones de ejemplo.

PUESTOC.U.U.BIDS
JA8NEE2
BEKVS11
KAHTH41
KAHTH41
KAHTH41
<BidPosition />

Data-driven

La 1ª posición es live (naranja + trofeo), el resto vault.

PUESTOC.U.U.BIDS
rodrigo_8812
ana.valdez9
jp_motors7
<BidPosition positions={positions} />

Encabezados

Los títulos de columna son editables.

PUESTOPOSTORBIDS
rodrigo_8812
ana.valdez9
jp_motors7
<BidPosition rankLabel="PUESTO" nameLabel="POSTOR" bidsLabel="BIDS" positions={positions} />

API

PropTipoDefault
rankLabel
Encabezado columna izquierda.
string"PUESTO"
nameLabel
Encabezado columna central.
string"C.U.U."
bidsLabel
Encabezado columna derecha.
string"BIDS"
positions
Filas; la 1ª es live, el resto vault.
BidPositionItem[]5 ítems
classNamestring""
BidPositionItem.name
Usuario (columna central, se trunca).
string
BidPositionItem.value
Nº de bids (columna derecha).
string
BidPositionItem.rank
Override del ordinal.
string`${i + 1}°`

Código del componente

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

/**
 * BidPosition — Generado por Concorde
 * Fuente: Figma VOYAGER · "ConsolePositions" (2934:14554) +
 *         "Background+Border" (3188:11944 live · 3188:11955 vault)
 *
 * Tabla de posiciones de pujas: card glass (white 28%→4% + backdrop-blur, radio 16, borde gradiente, sombra)
 * con header de columnas (PUESTO · USUARIO · BIDS, decodificado del SVG) y N
 * filas tipo pastilla en grid de 3 columnas:
 *   · 1ª posición  → live (naranja #FF9639→#BE3D00, borde gradiente, trofeo dorado)
 *   · resto        → vault (morado #19004A→#3B1782→#2E0F70, borde #99A1AF)
 * Colores/gradientes/sombras copiados tal cual del SVG. Texto = data (`positions`),
 * porque en el SVG los textos son glifos vectorizados (placeholder), no editables.
 */

import { useEffect, useLayoutEffect, useRef } from "react";
import type { JSX } from "react";

export interface BidPositionItem {
  /**
   * Identidad estable de la fila (p. ej. el id de usuario de la API/socket).
   * Es lo que habilita la animación de reordenamiento: mientras el `id` se
   * mantenga, la fila se desliza a su nueva posición en vez de re-montarse.
   * Si se omite se usa el `name` (sin animación de reorden ante nombres iguales).
   */
  id?: string;
  /** Usuario / nombre (columna central) */
  name: string;
  /** Nº de bids (columna derecha) */
  value: string;
  /** Override del ordinal (default: índice + "°") */
  rank?: string;
}

export interface BidPositionProps {
  /** Encabezado columna izquierda (default "PUESTO") */
  rankLabel?: string;
  /** Encabezado columna central (default "C.U.U.") */
  nameLabel?: string;
  /** Encabezado columna derecha (default "BIDS") */
  bidsLabel?: string;
  /**
   * Posiciones (la 1ª es live, el resto vault). Pensado para alimentarse en
   * vivo desde una API/socket: pásalas como nuevo array en cada actualización
   * y, si las filas traen `id`, los cambios de orden se animan automáticamente.
   */
  positions?: BidPositionItem[];
  className?: string;
}

const DEFAULT_POSITIONS: BidPositionItem[] = [
  { id: "u1", name: "JA8NEE", value: "2" },
  { id: "u2", name: "BEKVS1", value: "1" },
  { id: "u3", name: "KAHTH4", value: "1" },
  { id: "u4", name: "KAHTH4", value: "1" },
  { id: "u5", name: "KAHTH4", value: "1" },
];

const STYLE_ID = "concorde-bidposition-styles";

const BIDPOSITION_STYLES = `
.pbidpos {
  box-sizing: border-box;
  width: 313px;
  max-width: 100%;
  border-radius: 16px;
  border: 1.5px solid transparent;
  /* GLASS oscuro: base navy (= el "behind transparent areas" #0A002E) + sheen
     blanco (paint0: white 28%→4%) + backdrop-blur (Figma "bg blur 40" → 20px) */
  background-image:
    linear-gradient(127deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0.07) 45%, rgba(255,255,255,0.03) 100%),
    linear-gradient(160deg, rgba(28,13,82,0.93) 0%, rgba(14,3,56,0.95) 100%),
    linear-gradient(125deg, rgba(255,255,255,0.9) 0%, rgba(244,172,89,0.7) 22%, rgba(132,96,229,0.7) 74.5%, rgba(255,255,255,0.9) 100%);
  background-origin: border-box;
  background-clip: padding-box, padding-box, border-box;
  -webkit-backdrop-filter: blur(20px);
  backdrop-filter: blur(20px);
  box-shadow: rgba(10,0,46,0.6) 0px 12px 36px -4px, inset 0 1px 0 rgba(255,255,255,0.22);
  padding: 0 16px 16px;
  font-family: var(--vmc-font-display, "Plus Jakarta Sans", -apple-system, sans-serif);
}
/* 3 columnas fijas como el SVG: 78 · 143 · 58 (= 279) */
.pbidpos__head,
.pbidpos__row {
  display: grid;
  grid-template-columns: 78fr 143fr 58fr;
  align-items: center;
}
.pbidpos__head {
  padding: 14px 0 11px;
  margin-bottom: 12px;
  border-bottom: 1px solid #E1E3E2;
}
.pbidpos__head span {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #ffffff;
  white-space: nowrap;
}
.pbidpos__rows { display: flex; flex-direction: column; gap: 8px; }
.pbidpos__row {
  box-sizing: border-box;
  height: 28px;
  border-radius: 14px;
  color: #ffffff;
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .pbidpos__row { transition: none !important; transform: none !important; }
}
/* alineación por columna (idéntica en header y filas) */
.pbidpos__c1 { text-align: center; }
.pbidpos__c2 { text-align: left; padding-left: 12px; }
.pbidpos__c3 { text-align: center; }
.pbidpos__rank { font-size: 12px; font-weight: 700; }
.pbidpos__value { font-size: 12px; font-weight: 700; white-space: nowrap; }
.pbidpos__name {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  padding-left: 12px;
}
.pbidpos__nametext {
  font-size: 12px;
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pbidpos__trophy { flex-shrink: 0; filter: drop-shadow(0 0 2px rgba(240,187,59,0.65)); }

/* 1ª posición — live (naranja, borde gradiente) */
.pbidpos__row--live {
  border: 1px solid transparent;
  background-image:
    linear-gradient(180deg, #FF9639 0%, #EF852E 40%, #BE3D00 100%),
    linear-gradient(120deg, #ffffff 0%, #F4AC59 22%, #8460E5 74.5%, #ffffff 100%);
  background-origin: border-box;
  background-clip: padding-box, border-box;
}
/* resto — vault (morado, borde gris) */
.pbidpos__row--vault {
  border: 1px solid #99A1AF;
  background: linear-gradient(90deg, #19004A 0%, #3B1782 50%, #2E0F70 100%);
}
`;

function Trophy(): JSX.Element {
  return (
    <svg className="pbidpos__trophy" width="14" height="14" viewBox="177.5 80.3 12.5 12" fill="none" aria-hidden="true">
      <path
        d="M187.471 81.1675H180.476V82.3333H178.145V84.0821C178.145 85.3698 179.188 86.4137 180.476 86.4137C180.935 87.6649 182.065 88.5465 183.391 88.6871V90.4941H181.642V91.66H186.305V90.4941H184.557V88.6871C185.882 88.5465 187.012 87.6649 187.471 86.4137C188.759 86.4137 189.803 85.3698 189.803 84.0821V82.3333H187.471V81.1675ZM180.476 85.2479C179.832 85.2479 179.31 84.7259 179.31 84.0821V83.4991H180.476V85.2479ZM188.637 84.0821C188.637 84.7259 188.115 85.2479 187.471 85.2479V83.4991H188.637V84.0821Z"
        fill="#FBC47D"
      />
    </svg>
  );
}

function keyOf(p: BidPositionItem, i: number): string {
  return p.id ?? `${p.name}-${i}`;
}

export default function BidPosition({
  rankLabel = "PUESTO",
  nameLabel = "C.U.U.",
  bidsLabel = "BIDS",
  positions = DEFAULT_POSITIONS,
  className = "",
}: BidPositionProps): JSX.Element {
  // Inyección de estilos en cliente (el <style> SSR de abajo cubre el primer paint)
  useEffect(function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    const el = document.createElement("style");
    el.id = STYLE_ID;
    el.textContent = BIDPOSITION_STYLES;
    document.head.appendChild(el);
  }, []);

  // ── FLIP: anima el deslizamiento de cada fila a su nueva posición ───────────
  // Usa offsetTop/offsetLeft (relativos al contenedor, NO al viewport) para que
  // el scroll de la página no genere deltas falsos. Solo corre cuando cambian las
  // posiciones (no en cada render ni al scrollear).
  const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const prevPos = useRef<Map<string, { top: number; left: number }>>(new Map());

  useLayoutEffect(function flip() {
    const refs = rowRefs.current;
    const before = prevPos.current;
    const after = new Map<string, { top: number; left: number }>();
    refs.forEach(function measure(node, key) {
      after.set(key, { top: node.offsetTop, left: node.offsetLeft });
    });
    refs.forEach(function play(node, key) {
      const a = before.get(key);
      const b = after.get(key);
      if (!a || !b) return;
      const dx = a.left - b.left;
      const dy = a.top - b.top;
      if (dx === 0 && dy === 0) return;
      // 1) coloca la fila en su posición anterior, sin transición
      node.style.transition = "transform 0s";
      node.style.transform = `translate(${dx}px, ${dy}px)`;
      // 2) fuerza reflow y deja que anime hasta su lugar real
      void node.offsetWidth;
      requestAnimationFrame(function release() {
        node.style.transition = "transform 0.5s cubic-bezier(0.22,1,0.36,1)";
        node.style.transform = "";
      });
    });
    prevPos.current = after;
  }, [positions]);

  return (
    <>
      <style id={`${STYLE_ID}-ssr`} suppressHydrationWarning dangerouslySetInnerHTML={{ __html: BIDPOSITION_STYLES }} />
      <section className={`pbidpos ${className}`.trim()} aria-label="Posiciones">
        <div className="pbidpos__head">
          <span className="pbidpos__c1">{rankLabel}</span>
          <span className="pbidpos__c2">{nameLabel}</span>
          <span className="pbidpos__c3">{bidsLabel}</span>
        </div>
        <div className="pbidpos__rows">
          {positions.map(function renderPos(p, i) {
            const live = i === 0;
            const k = keyOf(p, i);
            return (
              <div
                key={k}
                ref={function setRef(node) {
                  if (node) rowRefs.current.set(k, node);
                  else rowRefs.current.delete(k);
                }}
                className={`pbidpos__row ${live ? "pbidpos__row--live" : "pbidpos__row--vault"}`}
              >
                <span className="pbidpos__rank pbidpos__c1">{p.rank ?? `${i + 1}°`}</span>
                <span className="pbidpos__name">
                  <span className="pbidpos__nametext">{p.name}</span>
                  {live ? <Trophy /> : null}
                </span>
                <span className="pbidpos__value pbidpos__c3">{p.value}</span>
              </div>
            );
          })}
        </div>
      </section>
    </>
  );
}