feat: admin can answer questions without paying for testing

- Server: /api/respuesta/enviar checks admin cookie; if authorized,
  bypasses credit check and stores answer via insertAdminAnswer()
- DB: insertAdminAnswer() inserts directly into user_answers with
  username='Admin', skipping the credit budget entirely
- Frontend: ProposeAnswer checks /api/admin/status on mount; if admin
  is logged in, shows the answer form directly (orange Admin badge)
  instead of the payment tier selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 18:15:46 +01:00
parent f9a8e2544f
commit fe5bb5a5c2
3 changed files with 88 additions and 17 deletions

View File

@@ -621,6 +621,7 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
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);
@@ -637,6 +638,10 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
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
@@ -704,28 +709,33 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
async function handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault();
if (!credit || !activeRound) return;
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 }),
body: JSON.stringify({ text: text.trim(), token: credit?.token ?? "" }),
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
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 };
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
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("");
@@ -742,8 +752,8 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
const exhausted = credit !== null && credit.answersLeft <= 0;
const hasPrompt = !!(activeRound?.prompt);
const alreadySubmitted = submittedFor === activeRound?.num;
const canAnswer = credit && !exhausted && hasPrompt && !alreadySubmitted &&
(activeRound?.phase === "answering" || activeRound?.phase === "prompting");
const phaseOk = activeRound?.phase === "answering" || activeRound?.phase === "prompting";
const canAnswer = (isAdmin || (credit && !exhausted)) && hasPrompt && !alreadySubmitted && phaseOk;
// Verifying payment
if (verifying || (creditOkOrder && !credit)) {
@@ -828,6 +838,46 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
);
}
// 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: &ldquo;{submittedText}&rdquo;</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">