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:
68
frontend.tsx
68
frontend.tsx
@@ -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: “{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">
|
||||
|
||||
Reference in New Issue
Block a user