feat: time-based credits, JUGADORES leaderboard, fix footer visibility

- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
  - credits table with token, tier, expires_at, status lifecycle
  - /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
  - Polling-based token delivery to browser after Redsys URLOK redirect
  - localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
  - /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:43:25 +01:00
parent 2fac92356d
commit e772fb5cc0
6 changed files with 857 additions and 117 deletions

View File

@@ -450,13 +450,50 @@ function LeaderboardSection({
);
}
function PlayerLeaderboard({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 7);
const maxScore = sorted[0]?.[1] || 1;
return (
<div className="lb-section">
<div className="lb-section__head">
<span className="lb-section__label">Jugadores</span>
<a href="/pregunta" className="standings__link">Jugar</a>
</div>
<div className="lb-section__list">
{sorted.map(([name, score], i) => {
const pct = Math.round((score / maxScore) * 100);
return (
<div key={name} className="lb-entry lb-entry--active">
<div className="lb-entry__top">
<span className="lb-entry__rank">
{i === 0 && score > 0 ? "🏆" : i + 1}
</span>
<span className="lb-entry__name">{name}</span>
<span className="lb-entry__score">{score}</span>
</div>
<div className="lb-entry__bar">
<div
className="lb-entry__fill"
style={{ width: `${pct}%`, background: "var(--accent)" }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function Standings({
scores,
viewerScores,
playerScores,
activeRound,
}: {
scores: Record<string, number>;
viewerScores: Record<string, number>;
playerScores: Record<string, number>;
activeRound: RoundState | null;
}) {
const competing = activeRound
@@ -489,6 +526,23 @@ function Standings({
scores={viewerScores}
competing={competing}
/>
{Object.keys(playerScores).length > 0 && (
<PlayerLeaderboard scores={playerScores} />
)}
<div className="standings__footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p>
por{" "}
<a
href="https://cloudhost.es"
target="_blank"
rel="noopener noreferrer"
>
Cloud Host
</a>
{" "} La web simplificada, la nube gestionada
</p>
</div>
</aside>
);
}
@@ -519,6 +573,21 @@ function App() {
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const lastVotedRoundRef = useRef<number | null>(null);
const [playerScores, setPlayerScores] = useState<Record<string, number>>({});
useEffect(() => {
async function fetchPlayerScores() {
try {
const res = await fetch("/api/jugadores");
if (res.ok) setPlayerScores(await res.json());
} catch {
// ignore
}
}
fetchPlayerScores();
const interval = setInterval(fetchPlayerScores, 60_000);
return () => clearInterval(interval);
}, []);
// Countdown timer for viewer voting
useEffect(() => {
@@ -653,23 +722,13 @@ function App() {
)}
</main>
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
<Standings
scores={state.scores}
viewerScores={state.viewerScores ?? {}}
playerScores={playerScores}
activeRound={state.active}
/>
</div>
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p>
por{" "}
<a
href="https://cloudhost.es"
target="_blank"
rel="noopener noreferrer"
className="site-footer__link"
>
Cloud Host
</a>
{" "} La web simplificada, la nube gestionada
</p>
</footer>
</div>
);
}