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:
180
frontend.css
180
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) {
|
||||
|
||||
299
frontend.tsx
299
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<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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ConnectingScreen() {
|
||||
@@ -707,6 +1001,8 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProposeQuestion />
|
||||
|
||||
<footer className="site-footer">
|
||||
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
||||
<p>
|
||||
@@ -714,8 +1010,7 @@ function App() {
|
||||
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
|
||||
Cloud Host
|
||||
</a>
|
||||
{" "}— La web simplificada, la nube gestionada ·{" "}
|
||||
<a href="/pregunta">propón preguntas</a>
|
||||
{" "}— La web simplificada, la nube gestionada
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -635,8 +635,8 @@ const server = Bun.serve<WsData>({
|
||||
isTest,
|
||||
orderId,
|
||||
amount: tierInfo.amount,
|
||||
urlOk: `${baseUrl}/pregunta?credito_ok=${orderId}`,
|
||||
urlKo: `${baseUrl}/pregunta?ko=1`,
|
||||
urlOk: `${baseUrl}/?credito_ok=${orderId}`,
|
||||
urlKo: `${baseUrl}/?ko=1`,
|
||||
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
||||
productDescription: `Acceso argument.es — ${tierInfo.label}`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user