BidPosition
Tabla de posiciones de pujas; la 1ª destaca como live con trofeo.
PUESTOC.U.U.BIDS
1°rodrigo_8812
2°ana.valdez9
3°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
1°rodrigo_8812
2°ana.valdez11
3°jp_motors10
4°lucia.q6
5°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 bidpositionUso
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
1°JA8NEE2
2°BEKVS11
3°KAHTH41
4°KAHTH41
5°KAHTH41
<BidPosition />Data-driven
La 1ª posición es live (naranja + trofeo), el resto vault.
PUESTOC.U.U.BIDS
1°rodrigo_8812
2°ana.valdez9
3°jp_motors7
<BidPosition positions={positions} />Encabezados
Los títulos de columna son editables.
PUESTOPOSTORBIDS
1°rodrigo_8812
2°ana.valdez9
3°jp_motors7
<BidPosition rankLabel="PUESTO" nameLabel="POSTOR" bidsLabel="BIDS" positions={positions} />API
| Prop | Tipo | Default |
|---|---|---|
rankLabelEncabezado columna izquierda. | string | "PUESTO" |
nameLabelEncabezado columna central. | string | "C.U.U." |
bidsLabelEncabezado columna derecha. | string | "BIDS" |
positionsFilas; la 1ª es live, el resto vault. | BidPositionItem[] | 5 ítems |
className | string | "" |
BidPositionItem.nameUsuario (columna central, se trunca). | string | — |
BidPositionItem.valueNº de bids (columna derecha). | string | — |
BidPositionItem.rankOverride 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>
</>
);
}