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:
108
db.ts
108
db.ts
@@ -42,7 +42,7 @@ export function clearAllRounds() {
|
|||||||
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Questions (user-submitted via Redsys) ───────────────────────────────────
|
// ── Questions (user-submitted) ───────────────────────────────────────────────
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS questions (
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
@@ -50,27 +50,121 @@ db.exec(`
|
|||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
order_id TEXT NOT NULL UNIQUE,
|
order_id TEXT NOT NULL UNIQUE,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export function createPendingQuestion(text: string, orderId: string): number {
|
// Migration: add username column to pre-existing questions tables
|
||||||
const stmt = db.prepare("INSERT INTO questions (text, order_id) VALUES ($text, $orderId)");
|
try {
|
||||||
const result = stmt.run({ $text: text, $orderId: orderId });
|
db.exec("ALTER TABLE questions ADD COLUMN username TEXT NOT NULL DEFAULT ''");
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPendingQuestion(text: string, orderId: string, username = ""): number {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"INSERT INTO questions (text, order_id, username) VALUES ($text, $orderId, $username)"
|
||||||
|
);
|
||||||
|
const result = stmt.run({ $text: text, $orderId: orderId, $username: username });
|
||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Creates a question that is immediately ready (used for credit-based submissions). */
|
||||||
|
export function createPaidQuestion(text: string, username: string): void {
|
||||||
|
const orderId = crypto.randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
|
||||||
|
).run({ $text: text, $orderId: orderId, $username: username });
|
||||||
|
}
|
||||||
|
|
||||||
export function markQuestionPaid(orderId: string): boolean {
|
export function markQuestionPaid(orderId: string): boolean {
|
||||||
const stmt = db.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'");
|
const result = db
|
||||||
const result = stmt.run({ $orderId: orderId });
|
.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'")
|
||||||
|
.run({ $orderId: orderId });
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextPendingQuestion(): { id: number; text: string; order_id: string } | null {
|
export function getNextPendingQuestion(): { id: number; text: string; order_id: string } | null {
|
||||||
return db.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1")
|
return db
|
||||||
|
.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1")
|
||||||
.get() as { id: number; text: string; order_id: string } | null;
|
.get() as { id: number; text: string; order_id: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markQuestionUsed(id: number): void {
|
export function markQuestionUsed(id: number): void {
|
||||||
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Top 7 players by number of questions used, excluding anonymous. */
|
||||||
|
export function getPlayerScores(): Record<string, number> {
|
||||||
|
const rows = db
|
||||||
|
.query(
|
||||||
|
"SELECT username, COUNT(*) as score FROM questions WHERE status = 'used' AND username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
|
||||||
|
)
|
||||||
|
.all() as { username: string; score: number }[];
|
||||||
|
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credits (time-based access) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS credits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
order_id TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
expires_at INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function createPendingCredit(username: string, orderId: string, tier: string): string {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO credits (username, token, tier, order_id) VALUES ($username, $token, $tier, $orderId)"
|
||||||
|
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateCredit(
|
||||||
|
orderId: string,
|
||||||
|
expiresAt: number,
|
||||||
|
): { token: string; username: string } | null {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE credits SET status = 'active', expires_at = $expiresAt WHERE order_id = $orderId AND status = 'pending'"
|
||||||
|
).run({ $expiresAt: expiresAt, $orderId: orderId });
|
||||||
|
return db
|
||||||
|
.query("SELECT token, username FROM credits WHERE order_id = $orderId AND status = 'active'")
|
||||||
|
.get({ $orderId: orderId }) as { token: string; username: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreditByOrder(orderId: string): {
|
||||||
|
status: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
tier: string;
|
||||||
|
expiresAt: number | null;
|
||||||
|
} | null {
|
||||||
|
return db
|
||||||
|
.query("SELECT status, token, username, tier, expires_at as expiresAt FROM credits WHERE order_id = $orderId")
|
||||||
|
.get({ $orderId: orderId }) as {
|
||||||
|
status: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
tier: string;
|
||||||
|
expiresAt: number | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCreditToken(token: string): { username: string; expiresAt: number } | null {
|
||||||
|
const row = db
|
||||||
|
.query(
|
||||||
|
"SELECT username, expires_at FROM credits WHERE token = $token AND status = 'active'"
|
||||||
|
)
|
||||||
|
.get({ $token: token }) as { username: string; expires_at: number } | null;
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at < Date.now()) return null;
|
||||||
|
return { username: row.username, expiresAt: row.expires_at };
|
||||||
|
}
|
||||||
|
|||||||
29
frontend.css
29
frontend.css
@@ -736,28 +736,38 @@ body {
|
|||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
|
||||||
/* ── Site Footer ──────────────────────────────────────────────── */
|
/* ── Standings footer & branding ─────────────────────────────── */
|
||||||
|
|
||||||
.site-footer {
|
.standings__footer {
|
||||||
flex-shrink: 0;
|
padding-top: 14px;
|
||||||
padding: 14px 20px;
|
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
text-align: center;
|
font-size: 11px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.7;
|
line-height: 1.8;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer__link {
|
.standings__footer a {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer__link:hover {
|
.standings__footer a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Player leaderboard name ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.lb-entry__name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Desktop (1024px+) ───────────────────────────────────────── */
|
/* ── Desktop (1024px+) ───────────────────────────────────────── */
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -814,5 +824,4 @@ body {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer { display: none; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
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({
|
function Standings({
|
||||||
scores,
|
scores,
|
||||||
viewerScores,
|
viewerScores,
|
||||||
|
playerScores,
|
||||||
activeRound,
|
activeRound,
|
||||||
}: {
|
}: {
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
|
playerScores: Record<string, number>;
|
||||||
activeRound: RoundState | null;
|
activeRound: RoundState | null;
|
||||||
}) {
|
}) {
|
||||||
const competing = activeRound
|
const competing = activeRound
|
||||||
@@ -489,6 +526,23 @@ function Standings({
|
|||||||
scores={viewerScores}
|
scores={viewerScores}
|
||||||
competing={competing}
|
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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -519,6 +573,21 @@ function App() {
|
|||||||
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
||||||
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
||||||
const lastVotedRoundRef = useRef<number | 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
|
// Countdown timer for viewer voting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -653,23 +722,13 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Standings scores={state.scores} viewerScores={state.viewerScores ?? {}} activeRound={state.active} />
|
<Standings
|
||||||
|
scores={state.scores}
|
||||||
|
viewerScores={state.viewerScores ?? {}}
|
||||||
|
playerScores={playerScores}
|
||||||
|
activeRound={state.active}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
136
pregunta.css
136
pregunta.css
@@ -173,6 +173,7 @@ body {
|
|||||||
.pregunta__links {
|
.pregunta__links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,3 +186,138 @@ body {
|
|||||||
.pregunta__links a:hover {
|
.pregunta__links a:hover {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pregunta__links-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__link-btn:hover {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header row (logo + credit badge) ─────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__credit-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4caf7d;
|
||||||
|
background: rgba(76, 175, 125, 0.12);
|
||||||
|
border: 1px solid rgba(76, 175, 125, 0.25);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Success banner ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__success {
|
||||||
|
background: rgba(76, 175, 125, 0.1);
|
||||||
|
border: 1px solid rgba(76, 175, 125, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #4caf7d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Username input ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tier cards ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tier-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 18px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card:hover {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card--selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(217, 119, 87, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card__price {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Spinner ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pregunta-spin 0.8s linear infinite;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pregunta-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|||||||
432
pregunta.tsx
432
pregunta.tsx
@@ -1,33 +1,228 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./pregunta.css";
|
import "./pregunta.css";
|
||||||
|
|
||||||
|
// ── Types & constants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CreditInfo = {
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
expiresAt: number;
|
||||||
|
tier: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "argumentes_credito";
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ id: "dia", label: "1 día", price: "1€", days: 1 },
|
||||||
|
{ id: "semana", label: "1 semana", price: "5€", days: 7 },
|
||||||
|
{ id: "mes", label: "1 mes", price: "15€", days: 30 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TierId = (typeof TIERS)[number]["id"];
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadCredit(): CreditInfo | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const c = JSON.parse(raw) as CreditInfo;
|
||||||
|
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleDateString("es-ES", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysLeft(expiresAt: number): number {
|
||||||
|
return Math.max(0, Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRedsysForm(data: {
|
||||||
|
tpvUrl: string;
|
||||||
|
merchantParams: string;
|
||||||
|
signature: string;
|
||||||
|
signatureVersion: string;
|
||||||
|
}) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "POST";
|
||||||
|
form.action = data.tpvUrl;
|
||||||
|
for (const [name, value] of Object.entries({
|
||||||
|
Ds_SignatureVersion: data.signatureVersion,
|
||||||
|
Ds_MerchantParameters: data.merchantParams,
|
||||||
|
Ds_Signature: data.signature,
|
||||||
|
})) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = name;
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const isOk = params.get("ok") === "1";
|
const creditOkOrder = params.get("credito_ok");
|
||||||
const isKo = params.get("ko") === "1";
|
const isKo = params.get("ko") === "1";
|
||||||
|
|
||||||
|
// Credit state
|
||||||
|
const [credit, setCredit] = useState<CreditInfo | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Credit verification (polling after Redsys redirect)
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyError, setVerifyError] = useState(false);
|
||||||
|
|
||||||
|
// Purchase flow
|
||||||
|
const [selectedTier, setSelectedTier] = useState<TierId | null>(null);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [buying, setBuying] = useState(false);
|
||||||
|
const [buyError, setBuyError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Question submission
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
if (isOk) {
|
// Load credit from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setCredit(loadCredit());
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll for credit activation after Redsys redirect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!creditOkOrder || !loaded || credit) return;
|
||||||
|
|
||||||
|
setVerifying(true);
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 15;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
setVerifying(false);
|
||||||
|
setVerifyError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/credito/estado?order=${encodeURIComponent(creditOkOrder!)}`,
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
found: boolean;
|
||||||
|
status?: string;
|
||||||
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
tier?: string;
|
||||||
|
};
|
||||||
|
if (data.found && data.status === "active" && data.token && data.expiresAt) {
|
||||||
|
const newCredit: CreditInfo = {
|
||||||
|
token: data.token,
|
||||||
|
username: data.username ?? "",
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
tier: data.tier ?? "",
|
||||||
|
};
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
|
||||||
|
setCredit(newCredit);
|
||||||
|
setVerifying(false);
|
||||||
|
history.replaceState(null, "", "/pregunta");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry
|
||||||
|
}
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
}, [creditOkOrder, loaded, credit]);
|
||||||
|
|
||||||
|
async function handleBuyCredit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedTier) return;
|
||||||
|
setBuyError(null);
|
||||||
|
setBuying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/credito/iniciar", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tier: selectedTier, username: username.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
await submitRedsysForm(await res.json());
|
||||||
|
} catch (err) {
|
||||||
|
setBuyError(err instanceof Error ? err.message : "Error al procesar el pago");
|
||||||
|
setBuying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitQuestion(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!credit) return;
|
||||||
|
setSubmitError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/pregunta/enviar", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: text.trim(), token: credit.token }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text();
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setCredit(null);
|
||||||
|
throw new Error("Tu acceso ha expirado. Compra un nuevo plan.");
|
||||||
|
}
|
||||||
|
throw new Error(msg || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
setText("");
|
||||||
|
setSent(true);
|
||||||
|
setTimeout(() => setSent(false), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
return (
|
return (
|
||||||
<div className="pregunta">
|
<div className="pregunta">
|
||||||
<div className="pregunta__panel">
|
<div className="pregunta__panel">
|
||||||
<a href="/" className="pregunta__logo">
|
<a href="/" className="pregunta__logo">
|
||||||
<img src="/assets/logo.svg" alt="argument.es" />
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
</a>
|
</a>
|
||||||
<h1>¡Pregunta enviada!</h1>
|
|
||||||
<p className="pregunta__sub">
|
|
||||||
Tu pregunta se usará en el próximo sorteo entre las IAs. ¡Gracias por participar!
|
|
||||||
</p>
|
|
||||||
<a href="/" className="pregunta__btn">Volver al juego</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Payment failed ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (isKo) {
|
if (isKo) {
|
||||||
return (
|
return (
|
||||||
<div className="pregunta">
|
<div className="pregunta">
|
||||||
@@ -37,7 +232,7 @@ function App() {
|
|||||||
</a>
|
</a>
|
||||||
<h1>Pago cancelado</h1>
|
<h1>Pago cancelado</h1>
|
||||||
<p className="pregunta__sub">
|
<p className="pregunta__sub">
|
||||||
El pago no se completó. Tu pregunta no ha sido guardada.
|
El pago no se completó. Tu acceso no ha sido activado.
|
||||||
</p>
|
</p>
|
||||||
<a href="/pregunta" className="pregunta__btn">Intentar de nuevo</a>
|
<a href="/pregunta" className="pregunta__btn">Intentar de nuevo</a>
|
||||||
<div className="pregunta__links">
|
<div className="pregunta__links">
|
||||||
@@ -48,90 +243,165 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
// ── Verifying payment ─────────────────────────────────────────────────────
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/pregunta/iniciar", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ text: text.trim() }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text();
|
|
||||||
throw new Error(msg || `Error ${res.status}`);
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
tpvUrl: string;
|
|
||||||
merchantParams: string;
|
|
||||||
signature: string;
|
|
||||||
signatureVersion: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build and auto-submit the Redsys payment form
|
if (verifying || (creditOkOrder && !credit)) {
|
||||||
const form = document.createElement("form");
|
return (
|
||||||
form.method = "POST";
|
<div className="pregunta">
|
||||||
form.action = data.tpvUrl;
|
<div className="pregunta__panel">
|
||||||
for (const [name, value] of Object.entries({
|
<a href="/" className="pregunta__logo">
|
||||||
Ds_SignatureVersion: data.signatureVersion,
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
Ds_MerchantParameters: data.merchantParams,
|
</a>
|
||||||
Ds_Signature: data.signature,
|
<h1>Verificando tu pago…</h1>
|
||||||
})) {
|
<p className="pregunta__sub">
|
||||||
const input = document.createElement("input");
|
{verifyError
|
||||||
input.type = "hidden";
|
? "No se pudo confirmar el pago. Si se completó, espera unos segundos y recarga."
|
||||||
input.name = name;
|
: "Esto puede tardar unos segundos."}
|
||||||
input.value = value;
|
</p>
|
||||||
form.appendChild(input);
|
{verifyError ? (
|
||||||
}
|
<a href="/pregunta" className="pregunta__btn">Volver</a>
|
||||||
document.body.appendChild(form);
|
) : (
|
||||||
form.submit();
|
<div className="pregunta__spinner" />
|
||||||
} catch (err) {
|
)}
|
||||||
setError(err instanceof Error ? err.message : "Error al procesar la solicitud");
|
</div>
|
||||||
setSubmitting(false);
|
</div>
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Active credit — question form ─────────────────────────────────────────
|
||||||
|
|
||||||
|
if (credit) {
|
||||||
|
const days = daysLeft(credit.expiresAt);
|
||||||
|
return (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<div className="pregunta__header-row">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
<div className="pregunta__credit-badge">
|
||||||
|
{days === 0
|
||||||
|
? "Expira hoy"
|
||||||
|
: `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Hola, {credit.username}</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
Acceso activo hasta el {formatDate(credit.expiresAt)}.
|
||||||
|
Envía todas las preguntas que quieras.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sent && (
|
||||||
|
<div className="pregunta__success">
|
||||||
|
✓ ¡Pregunta enviada! Se usará en el próximo sorteo.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
|
||||||
|
<label htmlFor="pregunta-text" className="pregunta__label">
|
||||||
|
Tu pregunta (frase de completar)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="pregunta-text"
|
||||||
|
className="pregunta__textarea"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder='Ejemplo: "La peor cosa que puedes encontrar en ___"'
|
||||||
|
maxLength={200}
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="pregunta__hint">{text.length}/200 · mínimo 10</div>
|
||||||
|
{submitError && <div className="pregunta__error">{submitError}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="pregunta__submit"
|
||||||
|
disabled={submitting || text.trim().length < 10}
|
||||||
|
>
|
||||||
|
{submitting ? "Enviando…" : "Enviar pregunta"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="pregunta__links">
|
||||||
|
<a href="/">Ver el juego</a>
|
||||||
|
<span className="pregunta__links-sep">·</span>
|
||||||
|
<button
|
||||||
|
className="pregunta__link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setCredit(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No credit — tier selection ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tierInfo = selectedTier ? TIERS.find((t) => t.id === selectedTier) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pregunta">
|
<div className="pregunta">
|
||||||
<div className="pregunta__panel">
|
<div className="pregunta__panel">
|
||||||
<a href="/" className="pregunta__logo">
|
<a href="/" className="pregunta__logo">
|
||||||
<img src="/assets/logo.svg" alt="argument.es" />
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
</a>
|
</a>
|
||||||
<h1>Propón una pregunta</h1>
|
|
||||||
|
<h1>Propón preguntas al juego</h1>
|
||||||
<p className="pregunta__sub">
|
<p className="pregunta__sub">
|
||||||
Paga 1€ y tu pregunta de completar-la-frase se usará en el próximo sorteo entre las IAs.
|
Compra acceso por tiempo y envía todas las preguntas que quieras.
|
||||||
|
Las mejores preguntas se usan en lugar de las generadas por IA y
|
||||||
|
aparecerás en el marcador de Jugadores.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="pregunta__form">
|
<div className="tier-cards">
|
||||||
<label htmlFor="pregunta-text" className="pregunta__label">
|
{TIERS.map((tier) => (
|
||||||
Tu pregunta (frase de completar)
|
<button
|
||||||
</label>
|
key={tier.id}
|
||||||
<textarea
|
type="button"
|
||||||
id="pregunta-text"
|
className={`tier-card ${selectedTier === tier.id ? "tier-card--selected" : ""}`}
|
||||||
className="pregunta__textarea"
|
onClick={() => setSelectedTier(tier.id)}
|
||||||
value={text}
|
>
|
||||||
onChange={(e) => setText(e.target.value)}
|
<div className="tier-card__price">{tier.price}</div>
|
||||||
placeholder='Ejemplo: "Lo que más vergüenza da hacer en ___"'
|
<div className="tier-card__label">{tier.label}</div>
|
||||||
maxLength={200}
|
</button>
|
||||||
required
|
))}
|
||||||
rows={3}
|
</div>
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="pregunta__hint">
|
|
||||||
{text.length}/200 caracteres · mínimo 10
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="pregunta__error">{error}</div>}
|
{selectedTier && (
|
||||||
|
<form onSubmit={handleBuyCredit} className="pregunta__form">
|
||||||
<button
|
<label htmlFor="username" className="pregunta__label">
|
||||||
type="submit"
|
Tu nombre en el marcador
|
||||||
className="pregunta__submit"
|
</label>
|
||||||
disabled={submitting || text.trim().length < 10}
|
<input
|
||||||
>
|
id="username"
|
||||||
{submitting ? "Redirigiendo a pago…" : "Pagar 1€ y enviar"}
|
type="text"
|
||||||
</button>
|
className="pregunta__input"
|
||||||
</form>
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Ej: Malin"
|
||||||
|
maxLength={30}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{buyError && <div className="pregunta__error">{buyError}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="pregunta__submit"
|
||||||
|
disabled={buying || !username.trim()}
|
||||||
|
>
|
||||||
|
{buying
|
||||||
|
? "Redirigiendo…"
|
||||||
|
: `Pagar ${tierInfo?.price} — ${tierInfo?.label}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pregunta__links">
|
<div className="pregunta__links">
|
||||||
<a href="/">Volver al juego</a>
|
<a href="/">Volver al juego</a>
|
||||||
|
|||||||
178
server.ts
178
server.ts
@@ -5,7 +5,12 @@ import historyHtml from "./history.html";
|
|||||||
import adminHtml from "./admin.html";
|
import adminHtml from "./admin.html";
|
||||||
import broadcastHtml from "./broadcast.html";
|
import broadcastHtml from "./broadcast.html";
|
||||||
import preguntaHtml from "./pregunta.html";
|
import preguntaHtml from "./pregunta.html";
|
||||||
import { clearAllRounds, getRounds, getAllRounds, createPendingQuestion, markQuestionPaid } from "./db.ts";
|
import {
|
||||||
|
clearAllRounds, getRounds, getAllRounds,
|
||||||
|
createPendingQuestion, markQuestionPaid, createPaidQuestion,
|
||||||
|
createPendingCredit, activateCredit, getCreditByOrder, validateCreditToken,
|
||||||
|
getPlayerScores,
|
||||||
|
} from "./db.ts";
|
||||||
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
|
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
|
||||||
import {
|
import {
|
||||||
MODELS,
|
MODELS,
|
||||||
@@ -110,6 +115,12 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
|
|||||||
);
|
);
|
||||||
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
|
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
|
||||||
const ADMIN_COOKIE = "argumentes_admin";
|
const ADMIN_COOKIE = "argumentes_admin";
|
||||||
|
|
||||||
|
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string }> = {
|
||||||
|
dia: { days: 1, amount: 100, label: "1 día" },
|
||||||
|
semana: { days: 7, amount: 500, label: "1 semana" },
|
||||||
|
mes: { days: 30, amount: 1500, label: "1 mes" },
|
||||||
|
};
|
||||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
const requestWindows = new Map<string, number[]>();
|
const requestWindows = new Map<string, number[]>();
|
||||||
@@ -541,8 +552,27 @@ const server = Bun.serve<WsData>({
|
|||||||
if (isPaymentApproved(decoded)) {
|
if (isPaymentApproved(decoded)) {
|
||||||
const orderId = decoded["Ds_Order"] ?? "";
|
const orderId = decoded["Ds_Order"] ?? "";
|
||||||
if (orderId) {
|
if (orderId) {
|
||||||
const marked = markQuestionPaid(orderId);
|
// Try question order first, then credit order
|
||||||
log("INFO", "redsys", "Question marked as paid", { orderId, marked });
|
const markedQuestion = markQuestionPaid(orderId);
|
||||||
|
if (markedQuestion) {
|
||||||
|
log("INFO", "redsys", "Question marked as paid", { orderId });
|
||||||
|
} else {
|
||||||
|
const credit = getCreditByOrder(orderId);
|
||||||
|
if (credit && credit.status === "pending") {
|
||||||
|
const tierInfo = CREDIT_TIERS[credit.tier];
|
||||||
|
if (tierInfo) {
|
||||||
|
const expiresAt = Date.now() + tierInfo.days * 24 * 60 * 60 * 1000;
|
||||||
|
const activated = activateCredit(orderId, expiresAt);
|
||||||
|
if (activated) {
|
||||||
|
log("INFO", "redsys", "Credit activated", {
|
||||||
|
orderId,
|
||||||
|
username: activated.username,
|
||||||
|
tier: credit.tier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log("INFO", "redsys", "Payment not approved", {
|
log("INFO", "redsys", "Payment not approved", {
|
||||||
@@ -553,6 +583,148 @@ const server = Bun.serve<WsData>({
|
|||||||
return new Response("ok", { status: 200 });
|
return new Response("ok", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/credito/iniciar") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKey = process.env.REDSYS_SECRET_KEY;
|
||||||
|
const merchantCode = process.env.REDSYS_MERCHANT_CODE;
|
||||||
|
if (!secretKey || !merchantCode) {
|
||||||
|
return new Response("Pagos no configurados", { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimited(`credito:${ip}`, 5, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tier = "";
|
||||||
|
let username = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
tier = String((body as Record<string, unknown>).tier ?? "").trim();
|
||||||
|
username = String((body as Record<string, unknown>).username ?? "").trim();
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierInfo = CREDIT_TIERS[tier];
|
||||||
|
if (!tierInfo) {
|
||||||
|
return new Response("Tier inválido (dia | semana | mes)", { status: 400 });
|
||||||
|
}
|
||||||
|
if (!username || username.length < 1 || username.length > 30) {
|
||||||
|
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = String(Date.now()).slice(-12);
|
||||||
|
createPendingCredit(username, orderId, tier);
|
||||||
|
|
||||||
|
const isTest = process.env.REDSYS_TEST !== "false";
|
||||||
|
const terminal = process.env.REDSYS_TERMINAL ?? "1";
|
||||||
|
const baseUrl =
|
||||||
|
process.env.PUBLIC_URL?.replace(/\/$/, "") ??
|
||||||
|
`${url.protocol}//${url.host}`;
|
||||||
|
|
||||||
|
const form = buildPaymentForm({
|
||||||
|
secretKey,
|
||||||
|
merchantCode,
|
||||||
|
terminal,
|
||||||
|
isTest,
|
||||||
|
orderId,
|
||||||
|
amount: tierInfo.amount,
|
||||||
|
urlOk: `${baseUrl}/pregunta?credito_ok=${orderId}`,
|
||||||
|
urlKo: `${baseUrl}/pregunta?ko=1`,
|
||||||
|
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
||||||
|
productDescription: `Acceso argument.es — ${tierInfo.label}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
log("INFO", "credito", "Credit purchase initiated", {
|
||||||
|
orderId,
|
||||||
|
tier,
|
||||||
|
ip,
|
||||||
|
username: username.slice(0, 10),
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({ ok: true, ...form }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/credito/estado") {
|
||||||
|
const orderId = url.searchParams.get("order") ?? "";
|
||||||
|
if (!orderId) {
|
||||||
|
return new Response("Missing order", { status: 400 });
|
||||||
|
}
|
||||||
|
const credit = getCreditByOrder(orderId);
|
||||||
|
if (!credit) {
|
||||||
|
return new Response(JSON.stringify({ found: false }), {
|
||||||
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
found: true,
|
||||||
|
status: credit.status,
|
||||||
|
...(credit.status === "active"
|
||||||
|
? { token: credit.token, username: credit.username, expiresAt: credit.expiresAt, tier: credit.tier }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/pregunta/enviar") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isRateLimited(`pregunta:${ip}`, 20, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
let token = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
text = String((body as Record<string, unknown>).text ?? "").trim();
|
||||||
|
token = String((body as Record<string, unknown>).token ?? "").trim();
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return new Response("Token requerido", { status: 401 });
|
||||||
|
}
|
||||||
|
const credit = validateCreditToken(token);
|
||||||
|
if (!credit) {
|
||||||
|
return new Response("Crédito no válido o expirado", { status: 401 });
|
||||||
|
}
|
||||||
|
if (text.length < 10 || text.length > 200) {
|
||||||
|
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
createPaidQuestion(text, credit.username);
|
||||||
|
log("INFO", "pregunta", "Question submitted via credit", { username: credit.username, ip });
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/jugadores") {
|
||||||
|
return new Response(JSON.stringify(getPlayerScores()), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "public, max-age=30, stale-while-revalidate=60",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/admin/login") {
|
if (url.pathname === "/api/admin/login") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response("Method Not Allowed", {
|
return new Response("Method Not Allowed", {
|
||||||
|
|||||||
Reference in New Issue
Block a user