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:
91
frontend.tsx
91
frontend.tsx
@@ -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, tú 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, tú 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user