Compare commits
8 Commits
2abea42c18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 40c919fc64 | |||
| fe5bb5a5c2 | |||
| f9a8e2544f | |||
| 3e5c080466 | |||
| d42e93b013 | |||
| e772fb5cc0 | |||
| 2fac92356d | |||
| 4b0b9f8f50 |
18
.env.sample
18
.env.sample
@@ -26,3 +26,21 @@ MAX_WS_NEW_PER_SEC=50
|
|||||||
|
|
||||||
# Viewer vote broadcast debounce in ms (default: 250)
|
# Viewer vote broadcast debounce in ms (default: 250)
|
||||||
VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=250
|
VIEWER_VOTE_BROADCAST_DEBOUNCE_MS=250
|
||||||
|
|
||||||
|
# Auto-pause: seconds of inactivity before pausing when no viewers are connected (default: 60000 ms)
|
||||||
|
AUTOPAUSE_DELAY_MS=60000
|
||||||
|
|
||||||
|
# ── Redsys (optional — enables paid user question submissions at /pregunta) ────
|
||||||
|
|
||||||
|
# Public base URL of this server (used for Redsys redirect/notification URLs)
|
||||||
|
# Example: https://argument.es
|
||||||
|
PUBLIC_URL=https://argument.es
|
||||||
|
|
||||||
|
# Redsys merchant credentials (get these from your bank / Redsys portal)
|
||||||
|
REDSYS_MERCHANT_CODE=
|
||||||
|
REDSYS_TERMINAL=1
|
||||||
|
# Base64-encoded Redsys secret key (SHA-256 key from the merchant portal)
|
||||||
|
REDSYS_SECRET_KEY=
|
||||||
|
|
||||||
|
# Set to "false" to use the live Redsys gateway (default: test environment)
|
||||||
|
REDSYS_TEST=true
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -1,3 +1,74 @@
|
|||||||
# Quipslop
|
# argument.es
|
||||||
|
|
||||||
Streamed live on [twitch.tv/quipslop](https://twitch.tv/quipslop).
|
Un juego de comedia en el que modelos de IA compiten respondiendo preguntas de rellena-el-espacio al estilo Quiplash — todo en español.
|
||||||
|
|
||||||
|
Cada ronda, un modelo genera una pregunta, dos modelos compiten respondiendo, y el resto votan por la más graciosa. El público también puede votar directamente desde la web.
|
||||||
|
|
||||||
|
## Modelos participantes
|
||||||
|
|
||||||
|
- Gemini 3.1 Pro
|
||||||
|
- Kimi K2
|
||||||
|
- DeepSeek 3.2
|
||||||
|
- GPT-5.2
|
||||||
|
- Claude Opus 4.6
|
||||||
|
- Claude Sonnet 4.6
|
||||||
|
- Grok 4.1
|
||||||
|
|
||||||
|
## Inicio rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.sample .env
|
||||||
|
# Edita .env y añade tu OPENROUTER_API_KEY y ADMIN_SECRET
|
||||||
|
bun install
|
||||||
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre [http://localhost:5109](http://localhost:5109).
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.sample .env
|
||||||
|
# Edita .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
La base de datos SQLite se persiste en un volumen Docker (`argumentes_data`).
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
| Variable | Obligatoria | Por defecto | Descripción |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `OPENROUTER_API_KEY` | ✅ | — | Clave de API de OpenRouter |
|
||||||
|
| `ADMIN_SECRET` | ✅ | — | Contraseña del panel de administración |
|
||||||
|
| `PORT` | | `5109` | Puerto del servidor |
|
||||||
|
| `DATABASE_PATH` | | `argumentes.sqlite` | Ruta al archivo SQLite |
|
||||||
|
| `AUTOPAUSE_DELAY_MS` | | `60000` | ms sin espectadores antes de autopausar |
|
||||||
|
| `PUBLIC_URL` | | — | URL pública (necesaria para Redsys) |
|
||||||
|
| `REDSYS_MERCHANT_CODE` | | — | Código de comercio Redsys |
|
||||||
|
| `REDSYS_TERMINAL` | | `1` | Terminal Redsys |
|
||||||
|
| `REDSYS_SECRET_KEY` | | — | Clave secreta Redsys (Base64) |
|
||||||
|
| `REDSYS_TEST` | | `true` | `false` para usar el entorno de producción |
|
||||||
|
|
||||||
|
Ver `.env.sample` para todas las opciones.
|
||||||
|
|
||||||
|
## Panel de administración
|
||||||
|
|
||||||
|
Disponible en `/admin`. Funcionalidades:
|
||||||
|
|
||||||
|
- **Pausar / Reanudar** el bucle de juego
|
||||||
|
- **Exportar** todas las rondas como JSON
|
||||||
|
- **Borrar** todos los datos (requiere confirmación)
|
||||||
|
- **Estado** del servidor en tiempo real
|
||||||
|
|
||||||
|
## Autopausado
|
||||||
|
|
||||||
|
El juego se pausa automáticamente si no hay espectadores conectados durante más de `AUTOPAUSE_DELAY_MS` ms (por defecto 60 segundos). En cuanto se conecta un espectador, el juego se reanuda solo. El panel de administración muestra "Esperando espectadores…" en este estado.
|
||||||
|
|
||||||
|
## Preguntas del público (`/pregunta`)
|
||||||
|
|
||||||
|
Los espectadores pueden pagar 1€ a través de Redsys para proponer una pregunta de completar-la-frase. La pregunta se usa en el siguiente sorteo en lugar de generarla con IA. Requiere configurar las variables `REDSYS_*` en `.env`.
|
||||||
|
|
||||||
|
## Cómo funcionan las preguntas
|
||||||
|
|
||||||
|
El array `ALL_PROMPTS` en `prompts.ts` sirve únicamente como **guía de estilo**. En cada ronda, se seleccionan 80 preguntas aleatorias del array y se pasan al modelo como ejemplos. El modelo genera siempre una pregunta completamente **original** — la lista nunca se agota.
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function App() {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const disposition = response.headers.get("content-disposition") ?? "";
|
const disposition = response.headers.get("content-disposition") ?? "";
|
||||||
const fileNameMatch = disposition.match(/filename="([^"]+)"/i);
|
const fileNameMatch = disposition.match(/filename="([^"]+)"/i);
|
||||||
const fileName = fileNameMatch?.[1] ?? `quipslop-export-${Date.now()}.json`;
|
const fileName = fileNameMatch?.[1] ?? `argumentes-export-${Date.now()}.json`;
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type GameState = {
|
|||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
autoPaused?: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
type StateMessage = {
|
type StateMessage = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
const db = new Database("quipslop.sqlite");
|
const db = new Database("argumentes.sqlite");
|
||||||
const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all();
|
const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all();
|
||||||
console.log(rows);
|
console.log(rows);
|
||||||
|
|||||||
208
db.ts
208
db.ts
@@ -41,3 +41,211 @@ export function clearAllRounds() {
|
|||||||
db.exec("DELETE FROM rounds;");
|
db.exec("DELETE FROM rounds;");
|
||||||
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Questions (user-submitted) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
order_id TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: add username column to pre-existing questions tables
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
const result = db
|
||||||
|
.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'")
|
||||||
|
.run({ $orderId: orderId });
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
.get() as { id: number; text: string; order_id: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markQuestionUsed(id: number): void {
|
||||||
|
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top 7 players by total votes received on their answers, excluding anonymous. */
|
||||||
|
export function getPlayerScores(): Record<string, number> {
|
||||||
|
const rows = db
|
||||||
|
.query(
|
||||||
|
"SELECT username, SUM(votes) as score FROM user_answers WHERE 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]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist accumulated vote counts for user answers in a given round. */
|
||||||
|
export function persistUserAnswerVotes(roundNum: number, votes: Record<string, number>): void {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
"UPDATE user_answers SET votes = $votes WHERE round_num = $roundNum AND username = $username"
|
||||||
|
);
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const [username, voteCount] of Object.entries(votes)) {
|
||||||
|
stmt.run({ $votes: voteCount, $roundNum: roundNum, $username: username });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User answers (submitted during live rounds) ──────────────────────────────
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_answers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
round_num INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
votes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: add votes column to pre-existing user_answers tables
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE user_answers ADD COLUMN votes INTEGER NOT NULL DEFAULT 0");
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credits (answer-count-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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migrations for question-tracking columns
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE credits ADD COLUMN max_questions INTEGER");
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE credits ADD COLUMN questions_used INTEGER NOT NULL DEFAULT 0");
|
||||||
|
} catch {
|
||||||
|
// Column already exists — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPendingCredit(username: string, orderId: string, tier: string, maxQuestions: number | null): string {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO credits (username, token, tier, order_id, max_questions) VALUES ($username, $token, $tier, $orderId, $maxQuestions)"
|
||||||
|
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId, $maxQuestions: maxQuestions });
|
||||||
|
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;
|
||||||
|
maxQuestions: number | null;
|
||||||
|
questionsUsed: number;
|
||||||
|
} | null {
|
||||||
|
return db
|
||||||
|
.query(
|
||||||
|
"SELECT status, token, username, tier, expires_at as expiresAt, max_questions as maxQuestions, questions_used as questionsUsed FROM credits WHERE order_id = $orderId"
|
||||||
|
)
|
||||||
|
.get({ $orderId: orderId }) as {
|
||||||
|
status: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
tier: string;
|
||||||
|
expiresAt: number | null;
|
||||||
|
maxQuestions: number | null;
|
||||||
|
questionsUsed: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a user answer directly, bypassing credit checks (admin use). */
|
||||||
|
export function insertAdminAnswer(roundNum: number, text: string, username: string): void {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, 'admin')"
|
||||||
|
).run({ $roundNum: roundNum, $text: text, $username: username });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically validates a credit token, records a user answer for the given
|
||||||
|
* round, and decrements the answer budget. Returns null if the token is
|
||||||
|
* invalid or exhausted.
|
||||||
|
*/
|
||||||
|
export function submitUserAnswer(
|
||||||
|
token: string,
|
||||||
|
roundNum: number,
|
||||||
|
text: string,
|
||||||
|
): { username: string; answersLeft: number } | null {
|
||||||
|
const row = db
|
||||||
|
.query(
|
||||||
|
"SELECT username, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
|
||||||
|
)
|
||||||
|
.get({ $token: token }) as {
|
||||||
|
username: string;
|
||||||
|
max_questions: number;
|
||||||
|
questions_used: number;
|
||||||
|
} | null;
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.questions_used >= row.max_questions) return null;
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, $token)"
|
||||||
|
).run({ $roundNum: roundNum, $text: text, $username: row.username, $token: token });
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
|
||||||
|
).run({ $token: token });
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { username: row.username, answersLeft: row.max_questions - row.questions_used - 1 };
|
||||||
|
}
|
||||||
|
|||||||
307
frontend.css
307
frontend.css
@@ -736,6 +736,312 @@ 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 (inside main) ───────────────────────────────── */
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 16px 0 4px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User answers (audience) ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.user-answers {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answers__label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answers__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__vote {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-vote-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-vote-btn:hover {
|
||||||
|
border-color: #444;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-vote-btn--active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-vote-count {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__text {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Propose Answer widget ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.propose {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 14px 0 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__badge {
|
||||||
|
font-size: 11px;
|
||||||
|
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: 2px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__badge--empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__tiers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__tier {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__tier:hover { border-color: #444; }
|
||||||
|
|
||||||
|
.propose__tier--selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(217, 119, 87, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__tier__price {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__tier__label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__row--mt { margin-top: 2px; }
|
||||||
|
|
||||||
|
.propose__input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__input:focus { outline: none; border-color: #444; }
|
||||||
|
.propose__input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.propose__textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__textarea:focus { outline: none; border-color: #444; }
|
||||||
|
.propose__textarea::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.propose__btn {
|
||||||
|
padding: 7px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--sans);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__btn:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.propose__btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.propose__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__msg {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__msg--ok { color: #4caf7d; }
|
||||||
|
.propose__msg--error { color: #ff6b6b; }
|
||||||
|
|
||||||
|
.propose__spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: propose-spin 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes propose-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.propose__link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose__link-btn:hover { color: var(--text-dim); }
|
||||||
|
|
||||||
/* ── Desktop (1024px+) ───────────────────────────────────────── */
|
/* ── Desktop (1024px+) ───────────────────────────────────────── */
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -791,4 +1097,5 @@ body {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
519
frontend.tsx
519
frontend.tsx
@@ -33,6 +33,8 @@ type RoundState = {
|
|||||||
viewerVotesA?: number;
|
viewerVotesA?: number;
|
||||||
viewerVotesB?: number;
|
viewerVotesB?: number;
|
||||||
viewerVotingEndsAt?: number;
|
viewerVotingEndsAt?: number;
|
||||||
|
userAnswers?: { username: string; text: string }[];
|
||||||
|
userAnswerVotes?: Record<string, number>;
|
||||||
};
|
};
|
||||||
type GameState = {
|
type GameState = {
|
||||||
lastCompleted: RoundState | null;
|
lastCompleted: RoundState | null;
|
||||||
@@ -41,6 +43,7 @@ type GameState = {
|
|||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
autoPaused?: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
type StateMessage = {
|
type StateMessage = {
|
||||||
@@ -56,6 +59,69 @@ type ViewerCountMessage = {
|
|||||||
};
|
};
|
||||||
type ServerMessage = StateMessage | ViewerCountMessage;
|
type ServerMessage = StateMessage | ViewerCountMessage;
|
||||||
|
|
||||||
|
// ── Credit / Propose ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CreditInfo = {
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
expiresAt: number;
|
||||||
|
tier: string;
|
||||||
|
answersLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CREDIT_STORAGE_KEY = "argumentes_credito";
|
||||||
|
|
||||||
|
const PROPOSE_TIERS = [
|
||||||
|
{ id: "basico", label: "10 respuestas", price: "0,99€" },
|
||||||
|
{ id: "pro", label: "300 respuestas", price: "9,99€" },
|
||||||
|
{ id: "full", label: "1000 respuestas", price: "19,99€" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
|
||||||
|
|
||||||
|
function loadCredit(): CreditInfo | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const c = JSON.parse(raw) as CreditInfo & { questionsLeft?: number };
|
||||||
|
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
|
||||||
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Migrate old field name
|
||||||
|
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
|
||||||
|
c.answersLeft = c.questionsLeft;
|
||||||
|
}
|
||||||
|
return c as CreditInfo;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MODEL_COLORS: Record<string, string> = {
|
const MODEL_COLORS: Record<string, string> = {
|
||||||
@@ -272,12 +338,16 @@ function Arena({
|
|||||||
viewerVotingSecondsLeft,
|
viewerVotingSecondsLeft,
|
||||||
myVote,
|
myVote,
|
||||||
onVote,
|
onVote,
|
||||||
|
myUserAnswerVote,
|
||||||
|
onUserAnswerVote,
|
||||||
}: {
|
}: {
|
||||||
round: RoundState;
|
round: RoundState;
|
||||||
total: number | null;
|
total: number | null;
|
||||||
viewerVotingSecondsLeft: number;
|
viewerVotingSecondsLeft: number;
|
||||||
myVote: "A" | "B" | null;
|
myVote: "A" | "B" | null;
|
||||||
onVote: (side: "A" | "B") => void;
|
onVote: (side: "A" | "B") => void;
|
||||||
|
myUserAnswerVote: string | null;
|
||||||
|
onUserAnswerVote: (username: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [contA, contB] = round.contestants;
|
const [contA, contB] = round.contestants;
|
||||||
const showVotes = round.phase === "voting" || round.phase === "done";
|
const showVotes = round.phase === "voting" || round.phase === "done";
|
||||||
@@ -369,6 +439,43 @@ function Arena({
|
|||||||
{isDone && votesA === votesB && totalVotes > 0 && (
|
{isDone && votesA === votesB && totalVotes > 0 && (
|
||||||
<div className="tie-label">Empate</div>
|
<div className="tie-label">Empate</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{round.userAnswers && round.userAnswers.length > 0 && (
|
||||||
|
<div className="user-answers">
|
||||||
|
<div className="user-answers__label">Respuestas del público</div>
|
||||||
|
<div className="user-answers__list">
|
||||||
|
{round.userAnswers.map((a, i) => {
|
||||||
|
const voteCount = round.userAnswerVotes?.[a.username] ?? 0;
|
||||||
|
const isMyVote = myUserAnswerVote === a.username;
|
||||||
|
return (
|
||||||
|
<div key={i} className="user-answer">
|
||||||
|
<div className="user-answer__main">
|
||||||
|
<span className="user-answer__name">{a.username}</span>
|
||||||
|
<span className="user-answer__sep"> — </span>
|
||||||
|
<span className="user-answer__text">“{a.text}”</span>
|
||||||
|
</div>
|
||||||
|
{(showCountdown || voteCount > 0) && (
|
||||||
|
<div className="user-answer__vote">
|
||||||
|
{showCountdown && (
|
||||||
|
<button
|
||||||
|
className={`user-vote-btn ${isMyVote ? "user-vote-btn--active" : ""}`}
|
||||||
|
onClick={() => onUserAnswerVote(a.username)}
|
||||||
|
title="Votar"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{voteCount > 0 && (
|
||||||
|
<span className="user-vote-count">{voteCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -449,13 +556,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
|
||||||
@@ -473,9 +617,6 @@ function Standings({
|
|||||||
<a href="/history" className="standings__link">
|
<a href="/history" className="standings__link">
|
||||||
Historial
|
Historial
|
||||||
</a>
|
</a>
|
||||||
<a href="https://twitch.tv/argument_es" target="_blank" rel="noopener noreferrer" className="standings__link">
|
|
||||||
Twitch
|
|
||||||
</a>
|
|
||||||
<a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
|
<a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
|
||||||
Web
|
Web
|
||||||
</a>
|
</a>
|
||||||
@@ -491,10 +632,326 @@ function Standings({
|
|||||||
scores={viewerScores}
|
scores={viewerScores}
|
||||||
competing={competing}
|
competing={competing}
|
||||||
/>
|
/>
|
||||||
|
{Object.keys(playerScores).length > 0 && (
|
||||||
|
<PlayerLeaderboard scores={playerScores} />
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Propose Answer (inline widget) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const creditOkOrder = params.get("credito_ok");
|
||||||
|
const isKo = params.get("ko") === "1";
|
||||||
|
|
||||||
|
const [credit, setCredit] = useState<CreditInfo | null>(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyError, setVerifyError] = useState(false);
|
||||||
|
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [buying, setBuying] = useState(false);
|
||||||
|
const [buyError, setBuyError] = useState<string | null>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [submittedFor, setSubmittedFor] = useState<number | null>(null);
|
||||||
|
const [submittedText, setSubmittedText] = useState<string | null>(null);
|
||||||
|
const [koDismissed, setKoDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCredit(loadCredit());
|
||||||
|
setLoaded(true);
|
||||||
|
// Check if admin is logged in (cookie-based, no token needed)
|
||||||
|
fetch("/api/admin/status")
|
||||||
|
.then(r => { if (r.ok) setIsAdmin(true); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear submission state when a new round starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeRound?.num && submittedFor !== null && activeRound.num !== submittedFor) {
|
||||||
|
setSubmittedFor(null);
|
||||||
|
setSubmittedText(null);
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
}, [activeRound?.num]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!creditOkOrder || !loaded || credit) return;
|
||||||
|
setVerifying(true);
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (attempts >= 15) { 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;
|
||||||
|
answersLeft?: number;
|
||||||
|
};
|
||||||
|
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 ?? "",
|
||||||
|
answersLeft: data.answersLeft ?? 0,
|
||||||
|
};
|
||||||
|
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
|
||||||
|
setCredit(newCredit);
|
||||||
|
setVerifying(false);
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
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 handleSubmitAnswer(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!credit && !isAdmin) return;
|
||||||
|
if (!activeRound) return;
|
||||||
|
setSubmitError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/respuesta/enviar", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: text.trim(), token: credit?.token ?? "" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
if (!isAdmin) {
|
||||||
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
|
setCredit(null);
|
||||||
|
}
|
||||||
|
throw new Error("Crédito agotado o no válido.");
|
||||||
|
}
|
||||||
|
if (res.status === 409) throw new Error("La ronda aún no tiene pregunta activa.");
|
||||||
|
throw new Error(await res.text() || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json() as { ok: boolean; answersLeft: number };
|
||||||
|
if (credit) {
|
||||||
|
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
|
||||||
|
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
setCredit(updated);
|
||||||
|
}
|
||||||
|
setSubmittedFor(activeRound.num);
|
||||||
|
setSubmittedText(text.trim());
|
||||||
|
setText("");
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
|
||||||
|
const exhausted = credit !== null && credit.answersLeft <= 0;
|
||||||
|
const hasPrompt = !!(activeRound?.prompt);
|
||||||
|
const alreadySubmitted = submittedFor === activeRound?.num;
|
||||||
|
const phaseOk = activeRound?.phase === "answering" || activeRound?.phase === "prompting";
|
||||||
|
const canAnswer = (isAdmin || (credit && !exhausted)) && hasPrompt && !alreadySubmitted && phaseOk;
|
||||||
|
|
||||||
|
// Verifying payment
|
||||||
|
if (verifying || (creditOkOrder && !credit)) {
|
||||||
|
return (
|
||||||
|
<div className="propose">
|
||||||
|
<div className="propose__head">
|
||||||
|
<span className="propose__title">Verificando pago</span>
|
||||||
|
{!verifyError && <div className="propose__spinner" />}
|
||||||
|
</div>
|
||||||
|
{verifyError && (
|
||||||
|
<p className="propose__msg propose__msg--error">
|
||||||
|
No se pudo confirmar. Recarga si el pago se completó.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active credit
|
||||||
|
if (credit) {
|
||||||
|
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
|
||||||
|
return (
|
||||||
|
<div className="propose">
|
||||||
|
<div className="propose__head">
|
||||||
|
<span className="propose__title">Responde junto a las IAs · {credit.username}</span>
|
||||||
|
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alreadySubmitted && submittedText && (
|
||||||
|
<p className="propose__msg propose__msg--ok">
|
||||||
|
✓ Tu respuesta: “{submittedText}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAnswer && (
|
||||||
|
<form onSubmit={handleSubmitAnswer}>
|
||||||
|
<div className="propose__row">
|
||||||
|
<textarea
|
||||||
|
className="propose__textarea"
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
placeholder="Tu respuesta más graciosa…"
|
||||||
|
rows={2}
|
||||||
|
maxLength={150}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
|
||||||
|
{submitting ? "…" : "Enviar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="propose__hint">
|
||||||
|
{text.length}/150 ·{" "}
|
||||||
|
<button type="button" className="propose__link-btn" onClick={() => {
|
||||||
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
|
setCredit(null);
|
||||||
|
}}>
|
||||||
|
cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canAnswer && !alreadySubmitted && !exhausted && (
|
||||||
|
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exhausted && (
|
||||||
|
<div className="propose__row">
|
||||||
|
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>
|
||||||
|
Sin respuestas. Recarga para seguir jugando.
|
||||||
|
</p>
|
||||||
|
<button className="propose__btn" onClick={() => {
|
||||||
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
|
setCredit(null);
|
||||||
|
}}>Recargar</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: show answer form without requiring credit
|
||||||
|
if (isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="propose">
|
||||||
|
<div className="propose__head">
|
||||||
|
<span className="propose__title">Responde junto a las IAs</span>
|
||||||
|
<span className="propose__badge" style={{ color: "var(--accent)", borderColor: "var(--accent)", background: "rgba(217,119,87,0.1)" }}>Admin</span>
|
||||||
|
</div>
|
||||||
|
{alreadySubmitted && submittedText && (
|
||||||
|
<p className="propose__msg propose__msg--ok">✓ Tu respuesta: “{submittedText}”</p>
|
||||||
|
)}
|
||||||
|
{canAnswer && (
|
||||||
|
<form onSubmit={handleSubmitAnswer}>
|
||||||
|
<div className="propose__row">
|
||||||
|
<textarea
|
||||||
|
className="propose__textarea"
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
placeholder="Tu respuesta más graciosa…"
|
||||||
|
rows={2}
|
||||||
|
maxLength={150}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
|
||||||
|
{submitting ? "…" : "Enviar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="propose__hint">{text.length}/150</div>
|
||||||
|
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{!canAnswer && !alreadySubmitted && (
|
||||||
|
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier selection (purchase)
|
||||||
|
return (
|
||||||
|
<div className="propose">
|
||||||
|
<div className="propose__head">
|
||||||
|
<span className="propose__title">Responde junto a las IAs</span>
|
||||||
|
</div>
|
||||||
|
{isKo && !koDismissed && (
|
||||||
|
<p className="propose__msg propose__msg--error">
|
||||||
|
El pago no se completó.{" "}
|
||||||
|
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="propose__tiers">
|
||||||
|
{PROPOSE_TIERS.map(tier => (
|
||||||
|
<button
|
||||||
|
key={tier.id}
|
||||||
|
type="button"
|
||||||
|
className={`propose__tier ${selectedTier === tier.id ? "propose__tier--selected" : ""}`}
|
||||||
|
onClick={() => setSelectedTier(tier.id)}
|
||||||
|
>
|
||||||
|
<span className="propose__tier__price">{tier.price}</span>
|
||||||
|
<span className="propose__tier__label">{tier.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedTier && (
|
||||||
|
<form onSubmit={handleBuyCredit} className="propose__row propose__row--mt">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="propose__input"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="Tu nombre en el marcador"
|
||||||
|
maxLength={30}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" className="propose__btn" disabled={buying || !username.trim()}>
|
||||||
|
{buying ? "…" : `Pagar ${tierInfo?.price}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{buyError && <p className="propose__msg propose__msg--error">{buyError}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Connecting ───────────────────────────────────────────────────────────────
|
// ── Connecting ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ConnectingScreen() {
|
function ConnectingScreen() {
|
||||||
@@ -520,7 +977,23 @@ function App() {
|
|||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
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 [myUserAnswerVote, setMyUserAnswerVote] = useState<string | 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(() => {
|
||||||
@@ -539,11 +1012,12 @@ function App() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
|
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
|
||||||
|
|
||||||
// Reset my vote when a new round starts
|
// Reset my votes when a new round starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const roundNum = state?.active?.num ?? null;
|
const roundNum = state?.active?.num ?? null;
|
||||||
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
|
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
|
||||||
setMyVote(null);
|
setMyVote(null);
|
||||||
|
setMyUserAnswerVote(null);
|
||||||
lastVotedRoundRef.current = roundNum;
|
lastVotedRoundRef.current = roundNum;
|
||||||
}
|
}
|
||||||
}, [state?.active?.num]);
|
}, [state?.active?.num]);
|
||||||
@@ -561,6 +1035,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUserAnswerVote(username: string) {
|
||||||
|
setMyUserAnswerVote(username);
|
||||||
|
try {
|
||||||
|
await fetch("/api/vote/respuesta", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore network errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
||||||
@@ -619,7 +1106,7 @@ function App() {
|
|||||||
className="viewer-pill"
|
className="viewer-pill"
|
||||||
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
||||||
>
|
>
|
||||||
En pausa
|
{state.autoPaused ? "Esperando espectadores…" : "En pausa"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="viewer-pill" aria-live="polite">
|
<div className="viewer-pill" aria-live="polite">
|
||||||
@@ -638,6 +1125,8 @@ function App() {
|
|||||||
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
||||||
myVote={myVote}
|
myVote={myVote}
|
||||||
onVote={handleVote}
|
onVote={handleVote}
|
||||||
|
myUserAnswerVote={myUserAnswerVote}
|
||||||
|
onUserAnswerVote={handleUserAnswerVote}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="waiting">
|
<div className="waiting">
|
||||||
@@ -653,9 +1142,27 @@ function App() {
|
|||||||
<Dots />
|
<Dots />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ProposeAnswer activeRound={state.active} />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Cloud Host
|
||||||
|
</a>
|
||||||
|
{" "}— La web simplificada, la nube gestionada
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
31
game.ts
31
game.ts
@@ -67,6 +67,8 @@ export type RoundState = {
|
|||||||
viewerVotesA?: number;
|
viewerVotesA?: number;
|
||||||
viewerVotesB?: number;
|
viewerVotesB?: number;
|
||||||
viewerVotingEndsAt?: number;
|
viewerVotingEndsAt?: number;
|
||||||
|
userAnswers?: { username: string; text: string }[];
|
||||||
|
userAnswerVotes?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GameState = {
|
export type GameState = {
|
||||||
@@ -76,6 +78,7 @@ export type GameState = {
|
|||||||
viewerScores: Record<string, number>;
|
viewerScores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
|
autoPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,7 +267,7 @@ export async function callVote(
|
|||||||
return cleaned.startsWith("A") ? "A" : "B";
|
return cleaned.startsWith("A") ? "A" : "B";
|
||||||
}
|
}
|
||||||
|
|
||||||
import { saveRound } from "./db.ts";
|
import { saveRound, getNextPendingQuestion, markQuestionUsed, persistUserAnswerVotes } from "./db.ts";
|
||||||
|
|
||||||
// ── Game loop ───────────────────────────────────────────────────────────────
|
// ── Game loop ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -325,12 +328,21 @@ export async function runGame(
|
|||||||
|
|
||||||
// ── Prompt phase ──
|
// ── Prompt phase ──
|
||||||
try {
|
try {
|
||||||
const prompt = await withRetry(
|
// Use a user-submitted question if one is pending, otherwise call AI
|
||||||
() => callGeneratePrompt(prompter),
|
const pendingQ = getNextPendingQuestion();
|
||||||
(s) => isRealString(s, 10),
|
let prompt: string;
|
||||||
3,
|
if (pendingQ) {
|
||||||
`R${r}:prompt:${prompter.name}`,
|
markQuestionUsed(pendingQ.id);
|
||||||
);
|
prompt = pendingQ.text;
|
||||||
|
log("INFO", `R${r}:prompt`, "Using user-submitted question", { id: pendingQ.id });
|
||||||
|
} else {
|
||||||
|
prompt = await withRetry(
|
||||||
|
() => callGeneratePrompt(prompter),
|
||||||
|
(s) => isRealString(s, 10),
|
||||||
|
3,
|
||||||
|
`R${r}:prompt:${prompter.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state.generation !== roundGeneration) {
|
if (state.generation !== roundGeneration) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -487,6 +499,11 @@ export async function runGame(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist votes for user answers
|
||||||
|
if (round.userAnswerVotes && round.userAnswers && round.userAnswers.length > 0) {
|
||||||
|
persistUserAnswerVotes(round.num, round.userAnswerVotes);
|
||||||
|
}
|
||||||
|
|
||||||
// Archive round
|
// Archive round
|
||||||
saveRound(round);
|
saveRound(round);
|
||||||
state.completed = [...state.completed, round];
|
state.completed = [...state.completed, round];
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun server.ts",
|
"start": "bun server.ts",
|
||||||
"start:cli": "bun quipslop.tsx",
|
"start:dev": "bun --hot server.ts"
|
||||||
"start:web": "bun --hot server.ts",
|
|
||||||
"start:stream": "bun ./scripts/stream-browser.ts live",
|
|
||||||
"start:stream:dryrun": "bun ./scripts/stream-browser.ts dryrun"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
357
pregunta.css
Normal file
357
pregunta.css
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
/* ── Reset & Variables ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #111;
|
||||||
|
--border: #1c1c1c;
|
||||||
|
--text: #ededed;
|
||||||
|
--text-dim: #888;
|
||||||
|
--text-muted: #444;
|
||||||
|
--accent: #D97757;
|
||||||
|
--sans: 'Inter', -apple-system, sans-serif;
|
||||||
|
--mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logo ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__logo {
|
||||||
|
display: inline-flex;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__logo img {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typography ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__panel h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__sub {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__textarea {
|
||||||
|
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;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 88px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__error {
|
||||||
|
background: rgba(220, 60, 60, 0.08);
|
||||||
|
border: 1px solid rgba(220, 60, 60, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__submit {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--sans);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__submit:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__submit:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status button (ok/ko states) ──────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__btn:hover {
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quick links ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__links a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__links a:hover {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__credit-badge--empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Site footer ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__footer a {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__footer a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
20
pregunta.html
Normal file
20
pregunta.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Propón una pregunta — argument.es</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="./pregunta.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./pregunta.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
457
pregunta.tsx
Normal file
457
pregunta.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./pregunta.css";
|
||||||
|
|
||||||
|
// ── Types & constants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CreditInfo = {
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
expiresAt: number;
|
||||||
|
tier: string;
|
||||||
|
questionsLeft: number | null; // null = unlimited
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "argumentes_credito";
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ id: "basico", label: "10 preguntas", sublabel: "30 días", price: "0,99€", maxQuestions: 10 },
|
||||||
|
{ id: "pro", label: "200 preguntas", sublabel: "30 días", price: "9,99€", maxQuestions: 200 },
|
||||||
|
{ id: "ilimitado", label: "Ilimitadas", sublabel: "30 días", price: "19,99€", maxQuestions: null },
|
||||||
|
] 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 badgeText(questionsLeft: number | null): string {
|
||||||
|
if (questionsLeft === null) return "Preguntas ilimitadas";
|
||||||
|
if (questionsLeft === 0) return "Sin preguntas restantes";
|
||||||
|
return `${questionsLeft} pregunta${questionsLeft !== 1 ? "s" : ""} restante${questionsLeft !== 1 ? "s" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<div className="pregunta__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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const creditOkOrder = params.get("credito_ok");
|
||||||
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
questionsLeft?: number | null;
|
||||||
|
};
|
||||||
|
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 ?? "",
|
||||||
|
questionsLeft: data.questionsLeft ?? null,
|
||||||
|
};
|
||||||
|
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 o se han agotado las preguntas.");
|
||||||
|
}
|
||||||
|
throw new Error(msg || `Error ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
|
||||||
|
// Update questionsLeft in state and localStorage
|
||||||
|
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
setCredit(updated);
|
||||||
|
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 (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment failed ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (isKo) {
|
||||||
|
return (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
<h1>Pago cancelado</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
El pago no se completó. Tu acceso no ha sido activado.
|
||||||
|
</p>
|
||||||
|
<a href="/pregunta" className="pregunta__btn">Intentar de nuevo</a>
|
||||||
|
<div className="pregunta__links">
|
||||||
|
<a href="/">Volver al juego</a>
|
||||||
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verifying payment ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (verifying || (creditOkOrder && !credit)) {
|
||||||
|
return (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
<h1>Verificando tu pago…</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
{verifyError
|
||||||
|
? "No se pudo confirmar el pago. Si se completó, espera unos segundos y recarga."
|
||||||
|
: "Esto puede tardar unos segundos."}
|
||||||
|
</p>
|
||||||
|
{verifyError ? (
|
||||||
|
<a href="/pregunta" className="pregunta__btn">Volver</a>
|
||||||
|
) : (
|
||||||
|
<div className="pregunta__spinner" />
|
||||||
|
)}
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active credit — question form ─────────────────────────────────────────
|
||||||
|
|
||||||
|
if (credit) {
|
||||||
|
const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0;
|
||||||
|
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 ${exhausted ? "pregunta__credit-badge--empty" : ""}`}>
|
||||||
|
{badgeText(credit.questionsLeft)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Hola, {credit.username}</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "}
|
||||||
|
{exhausted
|
||||||
|
? "Has agotado tus preguntas para este plan."
|
||||||
|
: "Envía todas las preguntas que quieras."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sent && (
|
||||||
|
<div className="pregunta__success">
|
||||||
|
✓ ¡Pregunta enviada! Se usará en el próximo sorteo.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!exhausted && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exhausted && (
|
||||||
|
<a href="/pregunta" className="pregunta__btn" onClick={() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}}>
|
||||||
|
Comprar nuevo plan
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No credit — tier selection ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tierInfo = selectedTier ? TIERS.find((t) => t.id === selectedTier) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pregunta">
|
||||||
|
<div className="pregunta__panel">
|
||||||
|
<a href="/" className="pregunta__logo">
|
||||||
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h1>Propón preguntas al juego</h1>
|
||||||
|
<p className="pregunta__sub">
|
||||||
|
Compra acceso por 30 días y envía preguntas ilimitadas o un paquete.
|
||||||
|
Las mejores se usan en lugar de las generadas por IA y
|
||||||
|
aparecerás en el marcador de Jugadores.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="tier-cards">
|
||||||
|
{TIERS.map((tier) => (
|
||||||
|
<button
|
||||||
|
key={tier.id}
|
||||||
|
type="button"
|
||||||
|
className={`tier-card ${selectedTier === tier.id ? "tier-card--selected" : ""}`}
|
||||||
|
onClick={() => setSelectedTier(tier.id)}
|
||||||
|
>
|
||||||
|
<div className="tier-card__price">{tier.price}</div>
|
||||||
|
<div className="tier-card__label">{tier.label}</div>
|
||||||
|
<div className="tier-card__sub">{tier.sublabel}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTier && (
|
||||||
|
<form onSubmit={handleBuyCredit} className="pregunta__form">
|
||||||
|
<label htmlFor="username" className="pregunta__label">
|
||||||
|
Tu nombre en el marcador
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
className="pregunta__input"
|
||||||
|
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">
|
||||||
|
<a href="/">Volver al juego</a>
|
||||||
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<App />);
|
||||||
110
redsys.ts
Normal file
110
redsys.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { createCipheriv, createHmac } from "node:crypto";
|
||||||
|
|
||||||
|
// ── Key derivation ────────────────────────────────────────────────────────────
|
||||||
|
// Redsys HMAC_SHA256_V1: derive a per-order signature key by 3DES-CBC encrypting
|
||||||
|
// the padded order ID with the merchant secret key.
|
||||||
|
|
||||||
|
function deriveKey(secretKeyBase64: string, orderId: string): Buffer {
|
||||||
|
const rawKey = Buffer.from(secretKeyBase64, "base64");
|
||||||
|
// 3DES-CBC requires exactly 24 bytes
|
||||||
|
const key24 = Buffer.alloc(24);
|
||||||
|
rawKey.copy(key24, 0, 0, Math.min(rawKey.length, 24));
|
||||||
|
|
||||||
|
// Pad order ID to a multiple of 8 bytes with zeros
|
||||||
|
const orderBuf = Buffer.from(orderId, "ascii");
|
||||||
|
const remainder = orderBuf.length % 8;
|
||||||
|
const paddedOrder =
|
||||||
|
remainder === 0 ? orderBuf : Buffer.concat([orderBuf, Buffer.alloc(8 - remainder)]);
|
||||||
|
|
||||||
|
// 3DES-CBC with 8-byte zero IV, no auto-padding
|
||||||
|
const iv = Buffer.alloc(8, 0);
|
||||||
|
const cipher = createCipheriv("des-ede3-cbc", key24, iv);
|
||||||
|
cipher.setAutoPadding(false);
|
||||||
|
return Buffer.concat([cipher.update(paddedOrder), cipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBase64(s: string): string {
|
||||||
|
return s.replace(/-/g, "+").replace(/_/g, "/").replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function encodeParams(params: Record<string, string>): string {
|
||||||
|
return Buffer.from(JSON.stringify(params)).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeParams(merchantParams: string): Record<string, string> {
|
||||||
|
// Redsys sends back URL-safe Base64; normalize before decoding
|
||||||
|
const normalized = merchantParams.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return JSON.parse(Buffer.from(normalized, "base64").toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sign(secretKeyBase64: string, orderId: string, data: string): string {
|
||||||
|
const derivedKey = deriveKey(secretKeyBase64, orderId);
|
||||||
|
return createHmac("sha256", derivedKey).update(data).digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPaymentApproved(decodedParams: Record<string, string>): boolean {
|
||||||
|
const code = parseInt(decodedParams["Ds_Response"] ?? "9999", 10);
|
||||||
|
return Number.isFinite(code) && code >= 0 && code <= 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedsysConfig = {
|
||||||
|
secretKey: string;
|
||||||
|
merchantCode: string;
|
||||||
|
terminal: string;
|
||||||
|
isTest: boolean;
|
||||||
|
orderId: string;
|
||||||
|
amount: number; // in cents
|
||||||
|
urlOk: string;
|
||||||
|
urlKo: string;
|
||||||
|
merchantUrl: string;
|
||||||
|
productDescription?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildPaymentForm(config: RedsysConfig): {
|
||||||
|
tpvUrl: string;
|
||||||
|
merchantParams: string;
|
||||||
|
signature: string;
|
||||||
|
signatureVersion: string;
|
||||||
|
} {
|
||||||
|
const tpvUrl = config.isTest
|
||||||
|
? "https://sis-t.redsys.es:25443/sis/realizarPago"
|
||||||
|
: "https://sis.redsys.es/sis/realizarPago";
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
DS_MERCHANT_AMOUNT: String(config.amount),
|
||||||
|
DS_MERCHANT_ORDER: config.orderId,
|
||||||
|
DS_MERCHANT_MERCHANTCODE: config.merchantCode,
|
||||||
|
DS_MERCHANT_CURRENCY: "978",
|
||||||
|
DS_MERCHANT_TRANSACTIONTYPE: "0",
|
||||||
|
DS_MERCHANT_TERMINAL: config.terminal,
|
||||||
|
DS_MERCHANT_URLOK: config.urlOk,
|
||||||
|
DS_MERCHANT_URLKO: config.urlKo,
|
||||||
|
DS_MERCHANT_MERCHANTURL: config.merchantUrl,
|
||||||
|
DS_MERCHANT_CONSUMERLANGUAGE: "001",
|
||||||
|
};
|
||||||
|
if (config.productDescription) {
|
||||||
|
params["DS_MERCHANT_PRODUCTDESCRIPTION"] = config.productDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchantParams = encodeParams(params);
|
||||||
|
const signature = sign(config.secretKey, config.orderId, merchantParams);
|
||||||
|
return { tpvUrl, merchantParams, signature, signatureVersion: "HMAC_SHA256_V1" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyNotification(
|
||||||
|
secretKeyBase64: string,
|
||||||
|
merchantParams: string,
|
||||||
|
receivedSignature: string,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const decoded = decodeParams(merchantParams);
|
||||||
|
const orderId = decoded["Ds_Order"] ?? decoded["DS_MERCHANT_ORDER"] ?? "";
|
||||||
|
if (!orderId) return false;
|
||||||
|
const expectedSig = sign(secretKeyBase64, orderId, merchantParams);
|
||||||
|
return normalizeBase64(expectedSig) === normalizeBase64(receivedSignature);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import puppeteer from "puppeteer";
|
|
||||||
|
|
||||||
type Mode = "live" | "dryrun";
|
|
||||||
|
|
||||||
type SinkWriter = {
|
|
||||||
write(chunk: Uint8Array): number;
|
|
||||||
end(error?: Error): number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
||||||
const parsed = Number.parseInt(value ?? "", 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(): never {
|
|
||||||
console.error("Usage: bun scripts/stream-browser.ts <live|dryrun>");
|
|
||||||
console.error("Required for live mode: TWITCH_STREAM_KEY");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMode(value: string | undefined): Mode {
|
|
||||||
if (value === "live" || value === "dryrun") return value;
|
|
||||||
return usage();
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = resolveMode(process.argv[2]);
|
|
||||||
|
|
||||||
const streamFps = parsePositiveInt(process.env.STREAM_FPS, 30);
|
|
||||||
const captureBitrate = parsePositiveInt(process.env.STREAM_CAPTURE_BITRATE, 12_000_000);
|
|
||||||
const targetSize = process.env.STREAM_TARGET_SIZE ?? "1920x1080";
|
|
||||||
const targetParts = targetSize.split("x");
|
|
||||||
const targetWidth = targetParts[0] ?? "1920";
|
|
||||||
const targetHeight = targetParts[1] ?? "1080";
|
|
||||||
const videoBitrate = process.env.STREAM_VIDEO_BITRATE ?? "6000k";
|
|
||||||
const maxrate = process.env.STREAM_MAXRATE ?? "6000k";
|
|
||||||
const bufsize = process.env.STREAM_BUFSIZE ?? "12000k";
|
|
||||||
const gop = String(parsePositiveInt(process.env.STREAM_GOP, 60));
|
|
||||||
const audioBitrate = process.env.STREAM_AUDIO_BITRATE ?? "160k";
|
|
||||||
const streamKey = process.env.TWITCH_STREAM_KEY;
|
|
||||||
const serverPort = process.env.STREAM_APP_PORT ?? "5109";
|
|
||||||
const broadcastUrl = process.env.BROADCAST_URL ?? `http://127.0.0.1:${serverPort}/broadcast`;
|
|
||||||
const redactionTokens = [
|
|
||||||
broadcastUrl,
|
|
||||||
streamKey,
|
|
||||||
streamKey ? encodeURIComponent(streamKey) : undefined,
|
|
||||||
streamKey ? `rtmp://live.twitch.tv/app/${streamKey}` : undefined,
|
|
||||||
].filter((token): token is string => Boolean(token));
|
|
||||||
const redactionWindow = Math.max(1, ...redactionTokens.map((token) => token.length));
|
|
||||||
|
|
||||||
function redactSensitive(value: string): string {
|
|
||||||
let output = value;
|
|
||||||
for (const token of redactionTokens) {
|
|
||||||
output = output.split(token).join("[REDACTED]");
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "live" && !streamKey) {
|
|
||||||
console.error("TWITCH_STREAM_KEY is not set.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertBroadcastReachable(url: string) {
|
|
||||||
const timeoutMs = 5_000;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(
|
|
||||||
`Cannot reach broadcast page (${redactSensitive(detail)}). Start the app server first (bun run start or bun run start:web).`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFfmpegArgs(currentMode: Mode): string[] {
|
|
||||||
const args = [
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"warning",
|
|
||||||
"-fflags",
|
|
||||||
"+genpts",
|
|
||||||
"-f",
|
|
||||||
"webm",
|
|
||||||
"-i",
|
|
||||||
"pipe:0",
|
|
||||||
"-f",
|
|
||||||
"lavfi",
|
|
||||||
"-i",
|
|
||||||
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
|
||||||
"-map",
|
|
||||||
"0:v:0",
|
|
||||||
"-map",
|
|
||||||
"1:a:0",
|
|
||||||
"-vf",
|
|
||||||
`scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`,
|
|
||||||
"-c:v",
|
|
||||||
"libx264",
|
|
||||||
"-preset",
|
|
||||||
"veryfast",
|
|
||||||
"-tune",
|
|
||||||
"zerolatency",
|
|
||||||
"-pix_fmt",
|
|
||||||
"yuv420p",
|
|
||||||
"-b:v",
|
|
||||||
videoBitrate,
|
|
||||||
"-maxrate",
|
|
||||||
maxrate,
|
|
||||||
"-bufsize",
|
|
||||||
bufsize,
|
|
||||||
"-g",
|
|
||||||
gop,
|
|
||||||
"-keyint_min",
|
|
||||||
gop,
|
|
||||||
"-sc_threshold",
|
|
||||||
"0",
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-b:a",
|
|
||||||
audioBitrate,
|
|
||||||
"-ar",
|
|
||||||
"44100",
|
|
||||||
"-ac",
|
|
||||||
"2",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (currentMode === "live") {
|
|
||||||
args.push("-f", "flv", `rtmp://live.twitch.tv/app/${streamKey}`);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("-f", "mpegts", "pipe:1");
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pipeReadableToSink(
|
|
||||||
readable: ReadableStream<Uint8Array>,
|
|
||||||
sink: SinkWriter,
|
|
||||||
) {
|
|
||||||
const reader = readable.getReader();
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (value) sink.write(value);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sink.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pipeReadableToRedactedStderr(readable: ReadableStream<Uint8Array>) {
|
|
||||||
const reader = readable.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let carry = "";
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const combined = carry + chunk;
|
|
||||||
if (combined.length <= redactionWindow) {
|
|
||||||
carry = combined;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const flushUntil = combined.length - (redactionWindow - 1);
|
|
||||||
const safeOutput = combined.slice(0, flushUntil);
|
|
||||||
carry = combined.slice(flushUntil);
|
|
||||||
if (safeOutput.length > 0) {
|
|
||||||
process.stderr.write(redactSensitive(safeOutput));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const trailing = carry + decoder.decode();
|
|
||||||
if (trailing.length > 0) {
|
|
||||||
process.stderr.write(redactSensitive(trailing));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await assertBroadcastReachable(broadcastUrl);
|
|
||||||
|
|
||||||
const ffmpegArgs = buildFfmpegArgs(mode);
|
|
||||||
const ffmpeg = Bun.spawn(["ffmpeg", ...ffmpegArgs], {
|
|
||||||
stdin: "pipe",
|
|
||||||
stdout: mode === "dryrun" ? "pipe" : "inherit",
|
|
||||||
stderr: "pipe",
|
|
||||||
});
|
|
||||||
if (ffmpeg.stderr) {
|
|
||||||
void pipeReadableToRedactedStderr(ffmpeg.stderr);
|
|
||||||
}
|
|
||||||
let ffmpegWritable = true;
|
|
||||||
|
|
||||||
let ffplay: Bun.Subprocess | null = null;
|
|
||||||
let ffplayPump: Promise<void> | null = null;
|
|
||||||
if (mode === "dryrun") {
|
|
||||||
ffplay = Bun.spawn(
|
|
||||||
[
|
|
||||||
"ffplay",
|
|
||||||
"-hide_banner",
|
|
||||||
"-fflags",
|
|
||||||
"nobuffer",
|
|
||||||
"-flags",
|
|
||||||
"low_delay",
|
|
||||||
"-framedrop",
|
|
||||||
"-i",
|
|
||||||
"pipe:0",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
stdin: "pipe",
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const stdout = ffmpeg.stdout;
|
|
||||||
if (!stdout || !ffplay.stdin) {
|
|
||||||
throw new Error("Failed to pipe ffmpeg output into ffplay.");
|
|
||||||
}
|
|
||||||
if (typeof ffplay.stdin === "number") {
|
|
||||||
throw new Error("ffplay stdin is not writable.");
|
|
||||||
}
|
|
||||||
ffplayPump = pipeReadableToSink(stdout, ffplay.stdin as SinkWriter);
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstChunkResolve: (() => void) | null = null;
|
|
||||||
let firstChunkReject: ((error: Error) => void) | null = null;
|
|
||||||
const firstChunk = new Promise<void>((resolve, reject) => {
|
|
||||||
firstChunkResolve = resolve;
|
|
||||||
firstChunkReject = reject;
|
|
||||||
});
|
|
||||||
let shutdown: (() => Promise<void>) | null = null;
|
|
||||||
|
|
||||||
const chunkServer = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
fetch(req, server) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
if (url.pathname === "/chunks" && server.upgrade(req)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
message(_ws, message) {
|
|
||||||
if (!ffmpegWritable || !ffmpeg.stdin || typeof ffmpeg.stdin === "number") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof message === "string") return;
|
|
||||||
|
|
||||||
let chunk: Uint8Array | null = null;
|
|
||||||
if (message instanceof ArrayBuffer) {
|
|
||||||
chunk = new Uint8Array(message);
|
|
||||||
} else if (ArrayBuffer.isView(message)) {
|
|
||||||
chunk = new Uint8Array(
|
|
||||||
message.buffer,
|
|
||||||
message.byteOffset,
|
|
||||||
message.byteLength,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!chunk) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
ffmpeg.stdin.write(chunk);
|
|
||||||
firstChunkResolve?.();
|
|
||||||
firstChunkResolve = null;
|
|
||||||
firstChunkReject = null;
|
|
||||||
} catch (error) {
|
|
||||||
ffmpegWritable = false;
|
|
||||||
const detail = error instanceof Error ? error : new Error(String(error));
|
|
||||||
firstChunkReject?.(detail);
|
|
||||||
firstChunkResolve = null;
|
|
||||||
firstChunkReject = null;
|
|
||||||
void shutdown?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: true,
|
|
||||||
args: [
|
|
||||||
"--autoplay-policy=no-user-gesture-required",
|
|
||||||
"--disable-background-timer-throttling",
|
|
||||||
"--disable-renderer-backgrounding",
|
|
||||||
"--disable-backgrounding-occluded-windows",
|
|
||||||
"--allow-running-insecure-content",
|
|
||||||
"--disable-features=LocalNetworkAccessChecks",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
|
|
||||||
page.on("console", (msg) => {
|
|
||||||
if (process.env.STREAM_DEBUG === "1") {
|
|
||||||
console.log(`[broadcast] ${msg.type()}: ${redactSensitive(msg.text())}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const captureUrl = new URL(broadcastUrl);
|
|
||||||
captureUrl.searchParams.set("sink", `ws://127.0.0.1:${chunkServer.port}/chunks`);
|
|
||||||
captureUrl.searchParams.set("captureFps", String(streamFps));
|
|
||||||
captureUrl.searchParams.set("captureBitrate", String(captureBitrate));
|
|
||||||
|
|
||||||
await page.goto(captureUrl.toString(), { waitUntil: "networkidle2" });
|
|
||||||
await page.waitForSelector("#broadcast-canvas", { timeout: 10_000 });
|
|
||||||
|
|
||||||
const firstChunkTimer = setTimeout(() => {
|
|
||||||
firstChunkReject?.(
|
|
||||||
new Error("No media chunks received from headless browser within 10s."),
|
|
||||||
);
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
await firstChunk.finally(() => clearTimeout(firstChunkTimer));
|
|
||||||
console.log(`Streaming broadcast in ${mode} mode`);
|
|
||||||
|
|
||||||
let shuttingDown = false;
|
|
||||||
shutdown = async () => {
|
|
||||||
if (shuttingDown) return;
|
|
||||||
shuttingDown = true;
|
|
||||||
ffmpegWritable = false;
|
|
||||||
try {
|
|
||||||
chunkServer.stop(true);
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
await browser.close();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
ffmpeg.stdin?.end();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
ffmpeg.kill();
|
|
||||||
} catch {}
|
|
||||||
if (ffplay) {
|
|
||||||
try {
|
|
||||||
if (ffplay.stdin && typeof ffplay.stdin !== "number") {
|
|
||||||
ffplay.stdin.end();
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
ffplay.kill();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ffmpegExit = ffmpeg.exited.then((code) => {
|
|
||||||
ffmpegWritable = false;
|
|
||||||
void shutdown?.();
|
|
||||||
return code;
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
void shutdown?.();
|
|
||||||
});
|
|
||||||
process.on("SIGTERM", () => {
|
|
||||||
void shutdown?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
const exitCode = await ffmpegExit;
|
|
||||||
if (ffplayPump) {
|
|
||||||
await ffplayPump.catch(() => {
|
|
||||||
// Ignore downstream pipe failures on shutdown.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (ffplay) {
|
|
||||||
await ffplay.exited;
|
|
||||||
}
|
|
||||||
await shutdown?.();
|
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error(redactSensitive(detail));
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
430
server.ts
430
server.ts
@@ -4,7 +4,14 @@ import indexHtml from "./index.html";
|
|||||||
import historyHtml from "./history.html";
|
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 { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
|
import preguntaHtml from "./pregunta.html";
|
||||||
|
import {
|
||||||
|
clearAllRounds, getRounds, getAllRounds,
|
||||||
|
createPendingCredit, activateCredit, getCreditByOrder,
|
||||||
|
submitUserAnswer, insertAdminAnswer,
|
||||||
|
getPlayerScores, persistUserAnswerVotes,
|
||||||
|
} from "./db.ts";
|
||||||
|
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
|
||||||
import {
|
import {
|
||||||
MODELS,
|
MODELS,
|
||||||
LOG_FILE,
|
LOG_FILE,
|
||||||
@@ -67,6 +74,7 @@ const gameState: GameState = {
|
|||||||
viewerScores: initialViewerScores,
|
viewerScores: initialViewerScores,
|
||||||
done: false,
|
done: false,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
|
autoPaused: false,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,7 +113,14 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
|
|||||||
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
|
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
|
||||||
250,
|
250,
|
||||||
);
|
);
|
||||||
|
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, { amount: number; label: string; maxAnswers: number }> = {
|
||||||
|
basico: { amount: 99, label: "10 respuestas", maxAnswers: 10 },
|
||||||
|
pro: { amount: 999, label: "300 respuestas", maxAnswers: 300 },
|
||||||
|
full: { amount: 1999, label: "1000 respuestas", maxAnswers: 1000 },
|
||||||
|
};
|
||||||
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[]>();
|
||||||
@@ -293,10 +308,35 @@ function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auto-pause ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let autoPauseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function scheduleAutoPause() {
|
||||||
|
if (autoPauseTimer) return;
|
||||||
|
autoPauseTimer = setTimeout(() => {
|
||||||
|
autoPauseTimer = null;
|
||||||
|
if (clients.size === 0 && !gameState.isPaused) {
|
||||||
|
gameState.isPaused = true;
|
||||||
|
gameState.autoPaused = true;
|
||||||
|
broadcast();
|
||||||
|
log("INFO", "server", "Auto-paused game — no viewers");
|
||||||
|
}
|
||||||
|
}, AUTOPAUSE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAutoPause() {
|
||||||
|
if (autoPauseTimer) {
|
||||||
|
clearTimeout(autoPauseTimer);
|
||||||
|
autoPauseTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── WebSocket clients ───────────────────────────────────────────────────────
|
// ── WebSocket clients ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const clients = new Set<ServerWebSocket<WsData>>();
|
const clients = new Set<ServerWebSocket<WsData>>();
|
||||||
const viewerVoters = new Map<string, "A" | "B">();
|
const viewerVoters = new Map<string, "A" | "B">();
|
||||||
|
const userAnswerVoters = new Map<string, string>(); // voterIp → voted-for username
|
||||||
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function scheduleViewerVoteBroadcast() {
|
function scheduleViewerVoteBroadcast() {
|
||||||
@@ -315,6 +355,7 @@ function getClientState() {
|
|||||||
viewerScores: gameState.viewerScores,
|
viewerScores: gameState.viewerScores,
|
||||||
done: gameState.done,
|
done: gameState.done,
|
||||||
isPaused: gameState.isPaused,
|
isPaused: gameState.isPaused,
|
||||||
|
autoPaused: gameState.autoPaused,
|
||||||
generation: gameState.generation,
|
generation: gameState.generation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -369,6 +410,7 @@ const server = Bun.serve<WsData>({
|
|||||||
"/history": historyHtml,
|
"/history": historyHtml,
|
||||||
"/admin": adminHtml,
|
"/admin": adminHtml,
|
||||||
"/broadcast": broadcastHtml,
|
"/broadcast": broadcastHtml,
|
||||||
|
"/pregunta": preguntaHtml,
|
||||||
},
|
},
|
||||||
async fetch(req, server) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -422,6 +464,351 @@ const server = Bun.serve<WsData>({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/vote/respuesta") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
username = String((body as Record<string, unknown>).username ?? "").trim();
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = gameState.active;
|
||||||
|
const votingOpen =
|
||||||
|
round?.phase === "voting" &&
|
||||||
|
round.viewerVotingEndsAt &&
|
||||||
|
Date.now() <= round.viewerVotingEndsAt;
|
||||||
|
|
||||||
|
if (!votingOpen || !round) {
|
||||||
|
return new Response(JSON.stringify({ ok: false, reason: "voting closed" }), {
|
||||||
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = round.userAnswers ?? [];
|
||||||
|
if (!answers.some((a) => a.username === username)) {
|
||||||
|
return new Response("Unknown answer", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevVote = userAnswerVoters.get(ip);
|
||||||
|
if (prevVote !== username) {
|
||||||
|
// Undo previous vote
|
||||||
|
if (prevVote) {
|
||||||
|
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
|
||||||
|
round.userAnswerVotes[prevVote] = Math.max(0, (round.userAnswerVotes[prevVote] ?? 0) - 1);
|
||||||
|
}
|
||||||
|
userAnswerVoters.set(ip, username);
|
||||||
|
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
|
||||||
|
round.userAnswerVotes[username] = (round.userAnswerVotes[username] ?? 0) + 1;
|
||||||
|
scheduleViewerVoteBroadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/pregunta/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(`pregunta:${ip}`, 5, WINDOW_MS)) {
|
||||||
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
text = String((body as Record<string, unknown>).text ?? "").trim();
|
||||||
|
} catch {
|
||||||
|
return new Response("Invalid JSON body", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length < 10 || text.length > 200) {
|
||||||
|
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = String(Date.now()).slice(-12);
|
||||||
|
createPendingQuestion(text, orderId);
|
||||||
|
|
||||||
|
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: 100,
|
||||||
|
urlOk: `${baseUrl}/pregunta?ok=1`,
|
||||||
|
urlKo: `${baseUrl}/pregunta?ko=1`,
|
||||||
|
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
||||||
|
productDescription: "Pregunta argument.es",
|
||||||
|
});
|
||||||
|
|
||||||
|
log("INFO", "pregunta", "Payment initiated", { orderId, ip });
|
||||||
|
return new Response(JSON.stringify({ ok: true, ...form }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/redsys/notificacion") {
|
||||||
|
// Always return 200 so Redsys doesn't retry; log errors internally.
|
||||||
|
const secretKey = process.env.REDSYS_SECRET_KEY;
|
||||||
|
if (!secretKey || req.method !== "POST") {
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let merchantParams = "";
|
||||||
|
let receivedSignature = "";
|
||||||
|
try {
|
||||||
|
const body = await req.text();
|
||||||
|
const fd = new URLSearchParams(body);
|
||||||
|
merchantParams = fd.get("Ds_MerchantParameters") ?? "";
|
||||||
|
receivedSignature = fd.get("Ds_Signature") ?? "";
|
||||||
|
} catch {
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyNotification(secretKey, merchantParams, receivedSignature)) {
|
||||||
|
log("WARN", "redsys", "Invalid notification signature");
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeParams(merchantParams);
|
||||||
|
if (isPaymentApproved(decoded)) {
|
||||||
|
const orderId = decoded["Ds_Order"] ?? "";
|
||||||
|
if (orderId) {
|
||||||
|
// Try question order first, then credit order
|
||||||
|
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) {
|
||||||
|
// No time limit — set expiry 10 years out so existing checks pass
|
||||||
|
const expiresAt = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
||||||
|
const activated = activateCredit(orderId, expiresAt);
|
||||||
|
if (activated) {
|
||||||
|
log("INFO", "redsys", "Credit activated", {
|
||||||
|
orderId,
|
||||||
|
username: activated.username,
|
||||||
|
tier: credit.tier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log("INFO", "redsys", "Payment not approved", {
|
||||||
|
response: decoded["Ds_Response"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (basico | pro | full)", { 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, tierInfo.maxAnswers);
|
||||||
|
|
||||||
|
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}/?credito_ok=${orderId}`,
|
||||||
|
urlKo: `${baseUrl}/?ko=1`,
|
||||||
|
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
||||||
|
productDescription: `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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const answersLeft = credit.maxQuestions === null
|
||||||
|
? 0
|
||||||
|
: credit.maxQuestions - credit.questionsUsed;
|
||||||
|
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,
|
||||||
|
answersLeft,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/respuesta/enviar") {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: { Allow: "POST" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isRateLimited(`respuesta:${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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminMode = isAdminAuthorized(req, url);
|
||||||
|
|
||||||
|
if (!token && !adminMode) {
|
||||||
|
return new Response("Token requerido", { status: 401 });
|
||||||
|
}
|
||||||
|
if (text.length < 3 || text.length > 150) {
|
||||||
|
return new Response("La respuesta debe tener entre 3 y 150 caracteres", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = gameState.active;
|
||||||
|
if (!round || !round.prompt) {
|
||||||
|
return new Response("No hay ronda activa", { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let username: string;
|
||||||
|
let answersLeft: number;
|
||||||
|
|
||||||
|
if (adminMode) {
|
||||||
|
username = "Admin";
|
||||||
|
insertAdminAnswer(round.num, text, username);
|
||||||
|
answersLeft = 999;
|
||||||
|
} else {
|
||||||
|
const result = submitUserAnswer(token, round.num, text);
|
||||||
|
if (!result) {
|
||||||
|
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
|
||||||
|
}
|
||||||
|
username = result.username;
|
||||||
|
answersLeft = result.answersLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to live round state and broadcast
|
||||||
|
round.userAnswers = [...(round.userAnswers ?? []), { username, text }];
|
||||||
|
broadcast();
|
||||||
|
|
||||||
|
log("INFO", "respuesta", "Answer submitted", { username, round: round.num, admin: adminMode, ip });
|
||||||
|
return new Response(JSON.stringify({ ok: true, answersLeft }), {
|
||||||
|
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", {
|
||||||
@@ -559,6 +946,7 @@ const server = Bun.serve<WsData>({
|
|||||||
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
gameState.viewerScores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||||
gameState.done = false;
|
gameState.done = false;
|
||||||
gameState.isPaused = true;
|
gameState.isPaused = true;
|
||||||
|
gameState.autoPaused = false;
|
||||||
gameState.generation += 1;
|
gameState.generation += 1;
|
||||||
broadcast();
|
broadcast();
|
||||||
|
|
||||||
@@ -593,8 +981,11 @@ const server = Bun.serve<WsData>({
|
|||||||
|
|
||||||
if (url.pathname.endsWith("/pause")) {
|
if (url.pathname.endsWith("/pause")) {
|
||||||
gameState.isPaused = true;
|
gameState.isPaused = true;
|
||||||
|
gameState.autoPaused = false;
|
||||||
|
cancelAutoPause();
|
||||||
} else {
|
} else {
|
||||||
gameState.isPaused = false;
|
gameState.isPaused = false;
|
||||||
|
gameState.autoPaused = false;
|
||||||
}
|
}
|
||||||
broadcast();
|
broadcast();
|
||||||
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
|
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
|
||||||
@@ -712,17 +1103,28 @@ const server = Bun.serve<WsData>({
|
|||||||
totalClients: clients.size,
|
totalClients: clients.size,
|
||||||
uniqueIps: wsByIp.size,
|
uniqueIps: wsByIp.size,
|
||||||
});
|
});
|
||||||
// Send current state to the new client only
|
|
||||||
ws.send(
|
cancelAutoPause();
|
||||||
JSON.stringify({
|
|
||||||
type: "state",
|
if (gameState.autoPaused) {
|
||||||
data: getClientState(),
|
gameState.isPaused = false;
|
||||||
totalRounds: runs,
|
gameState.autoPaused = false;
|
||||||
viewerCount: clients.size,
|
log("INFO", "server", "Auto-resumed game — viewer connected");
|
||||||
version: VERSION,
|
// Broadcast updated state to all clients (including this new one)
|
||||||
}),
|
broadcast();
|
||||||
);
|
} else {
|
||||||
// Notify everyone else with just the viewer count
|
// Send current state to the new client only
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "state",
|
||||||
|
data: getClientState(),
|
||||||
|
totalRounds: runs,
|
||||||
|
viewerCount: clients.size,
|
||||||
|
version: VERSION,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Notify everyone of updated viewer count
|
||||||
broadcastViewerCount();
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
message() {
|
message() {
|
||||||
@@ -736,6 +1138,9 @@ const server = Bun.serve<WsData>({
|
|||||||
totalClients: clients.size,
|
totalClients: clients.size,
|
||||||
uniqueIps: wsByIp.size,
|
uniqueIps: wsByIp.size,
|
||||||
});
|
});
|
||||||
|
if (clients.size === 0 && !gameState.isPaused) {
|
||||||
|
scheduleAutoPause();
|
||||||
|
}
|
||||||
broadcastViewerCount();
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -768,6 +1173,7 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
|
|||||||
|
|
||||||
runGame(runs, gameState, broadcast, () => {
|
runGame(runs, gameState, broadcast, () => {
|
||||||
viewerVoters.clear();
|
viewerVoters.clear();
|
||||||
|
userAnswerVoters.clear();
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
|
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user