feat: embed propose-question widget inline in main game page

- Move /pregunta flow into a compact ProposeQuestion component
  rendered inside <main> 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:21:35 +01:00
parent d42e93b013
commit 3e5c080466
3 changed files with 479 additions and 4 deletions

View File

@@ -769,6 +769,186 @@ body {
white-space: nowrap; 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+) ───────────────────────────────────────── */ /* ── Desktop (1024px+) ───────────────────────────────────────── */
@media (min-width: 1024px) { @media (min-width: 1024px) {

View File

@@ -57,6 +57,65 @@ type ViewerCountMessage = {
}; };
type ServerMessage = StateMessage | 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 ───────────────────────────────────────────────────── // ── Model colors & logos ─────────────────────────────────────────────────────
const MODEL_COLORS: Record<string, string> = { const MODEL_COLORS: Record<string, string> = {
@@ -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<CreditInfo | null>(null);
const [loaded, setLoaded] = 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 [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 (
<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 — question form
if (credit) {
const badge = credit.questionsLeft === null
? "Ilimitadas"
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Propón una pregunta · {credit.username}</span>
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
</div>
{sent && <p className="propose__msg propose__msg--ok"> ¡Enviada! Se usará en el próximo sorteo.</p>}
{!exhausted ? (
<form onSubmit={handleSubmitQuestion}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder='"La peor cosa que puedes encontrar en ___"'
rows={2}
maxLength={200}
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 10}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">
{text.length}/200 · mín. 10 ·{" "}
<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>
) : (
<div className="propose__row">
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>Has agotado tus preguntas.</p>
<button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Nuevo plan</button>
</div>
)}
</div>
);
}
// Tier selection
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Propón preguntas al juego</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() {
@@ -707,6 +1001,8 @@ function App() {
</div> </div>
)} )}
<ProposeQuestion />
<footer className="site-footer"> <footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p> <p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p> <p>
@@ -714,8 +1010,7 @@ function App() {
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer"> <a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
Cloud Host Cloud Host
</a> </a>
{" "} La web simplificada, la nube gestionada ·{" "} {" "} La web simplificada, la nube gestionada
<a href="/pregunta">propón preguntas</a>
</p> </p>
</footer> </footer>
</main> </main>

View File

@@ -635,8 +635,8 @@ const server = Bun.serve<WsData>({
isTest, isTest,
orderId, orderId,
amount: tierInfo.amount, amount: tierInfo.amount,
urlOk: `${baseUrl}/pregunta?credito_ok=${orderId}`, urlOk: `${baseUrl}/?credito_ok=${orderId}`,
urlKo: `${baseUrl}/pregunta?ko=1`, urlKo: `${baseUrl}/?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`, merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: `Acceso argument.es — ${tierInfo.label}`, productDescription: `Acceso argument.es — ${tierInfo.label}`,
}); });