From 3e5c080466c2a3c8432d07966dfbb2603e43ab46 Mon Sep 17 00:00:00 2001 From: Malin Date: Fri, 27 Feb 2026 17:21:35 +0100 Subject: [PATCH] feat: embed propose-question widget inline in main game page - Move /pregunta flow into a compact ProposeQuestion component rendered inside
above the footer - Tier cards, username input, Redsys payment, polling, and question submission all work inline without leaving the game page - Payment redirects now go to /?credito_ok=ORDER and /?ko=1 so the game stays in view during the full payment cycle - Badge shows live questions remaining; updates on each submission - Removed /pregunta link from footer (functionality is now inline) - .propose-* CSS: compact tier grid, textarea+button row, spinner Co-Authored-By: Claude Sonnet 4.6 --- frontend.css | 180 +++++++++++++++++++++++++++++++ frontend.tsx | 299 ++++++++++++++++++++++++++++++++++++++++++++++++++- server.ts | 4 +- 3 files changed, 479 insertions(+), 4 deletions(-) diff --git a/frontend.css b/frontend.css index 10732a7..a22d466 100644 --- a/frontend.css +++ b/frontend.css @@ -769,6 +769,186 @@ body { white-space: nowrap; } +/* ── Propose Question 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+) ───────────────────────────────────────── */ @media (min-width: 1024px) { diff --git a/frontend.tsx b/frontend.tsx index 58a45d2..d354625 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -57,6 +57,65 @@ type ViewerCountMessage = { }; type ServerMessage = StateMessage | ViewerCountMessage; +// ── Credit / Propose ───────────────────────────────────────────────────────── + +type CreditInfo = { + token: string; + username: string; + expiresAt: number; + tier: string; + questionsLeft: number | null; +}; + +const CREDIT_STORAGE_KEY = "argumentes_credito"; + +const PROPOSE_TIERS = [ + { id: "basico", label: "10 preguntas", price: "0,99€" }, + { id: "pro", label: "200 preguntas", price: "9,99€" }, + { id: "ilimitado", label: "Ilimitadas", 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; + if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) { + localStorage.removeItem(CREDIT_STORAGE_KEY); + return null; + } + return c; + } 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 ───────────────────────────────────────────────────── const MODEL_COLORS: Record = { @@ -533,6 +592,241 @@ function Standings({ ); } +// ── Propose Question (inline widget) ───────────────────────────────────────── + +function ProposeQuestion() { + const params = new URLSearchParams(window.location.search); + const creditOkOrder = params.get("credito_ok"); + const isKo = params.get("ko") === "1"; + + const [credit, setCredit] = useState(null); + const [loaded, setLoaded] = useState(false); + const [verifying, setVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(false); + const [selectedTier, setSelectedTier] = useState(null); + const [username, setUsername] = useState(""); + const [buying, setBuying] = useState(false); + const [buyError, setBuyError] = useState(null); + const [text, setText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [sent, setSent] = useState(false); + const [koDismissed, setKoDismissed] = useState(false); + + useEffect(() => { + setCredit(loadCredit()); + setLoaded(true); + }, []); + + 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; + 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(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 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) { + if (res.status === 401) { + localStorage.removeItem(CREDIT_STORAGE_KEY); + setCredit(null); + throw new Error("Acceso expirado o sin preguntas disponibles."); + } + throw new Error(await res.text() || `Error ${res.status}`); + } + const data = await res.json() as { ok: boolean; questionsLeft: number | null }; + const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft }; + localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated)); + setCredit(updated); + setText(""); + setSent(true); + setTimeout(() => setSent(false), 3000); + } 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.questionsLeft !== null && credit.questionsLeft <= 0; + + // Verifying payment + if (verifying || (creditOkOrder && !credit)) { + return ( +
+
+ Verificando pago + {!verifyError &&
} +
+ {verifyError && ( +

+ No se pudo confirmar. Recarga si el pago se completó. +

+ )} +
+ ); + } + + // Active credit — question form + if (credit) { + const badge = credit.questionsLeft === null + ? "Ilimitadas" + : `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`; + return ( +
+
+ Propón una pregunta · {credit.username} + {badge} +
+ {sent &&

✓ ¡Enviada! Se usará en el próximo sorteo.

} + {!exhausted ? ( +
+
+