feat: question-count tiers, footer in main, pregunta footer
- Pricing: 0,99€/10q · 9,99€/200q · 19,99€/unlimited (all 30 days) - DB: max_questions + questions_used columns on credits table; consumeCreditQuestion() atomically validates, creates question, and decrements quota in a single transaction - Server: updated CREDIT_TIERS, /api/credito/estado returns questionsLeft, /api/pregunta/enviar returns updated questionsLeft after each submission - pregunta.tsx: badge shows live question count; submit disabled when exhausted; questionsLeft synced to localStorage after each submission; Cloud Host footer added - footer: moved from Standings sidebar into <main> (scrolls with game content); also added to all pregunta page states Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
122
pregunta.tsx
122
pregunta.tsx
@@ -9,14 +9,15 @@ type CreditInfo = {
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
tier: string;
|
||||
questionsLeft: number | null; // null = unlimited
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "argumentes_credito";
|
||||
|
||||
const TIERS = [
|
||||
{ id: "dia", label: "1 día", price: "1€", days: 1 },
|
||||
{ id: "semana", label: "1 semana", price: "5€", days: 7 },
|
||||
{ id: "mes", label: "1 mes", price: "15€", days: 30 },
|
||||
{ id: "basico", label: "10 preguntas", sublabel: "30 días", price: "0,99€", maxQuestions: 10 },
|
||||
{ id: "pro", label: "200 preguntas", sublabel: "30 días", price: "9,99€", maxQuestions: 200 },
|
||||
{ id: "ilimitado", label: "Ilimitadas", sublabel: "30 días", price: "19,99€", maxQuestions: null },
|
||||
] as const;
|
||||
|
||||
type TierId = (typeof TIERS)[number]["id"];
|
||||
@@ -46,8 +47,10 @@ function formatDate(ms: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
function daysLeft(expiresAt: number): number {
|
||||
return Math.max(0, Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24)));
|
||||
function badgeText(questionsLeft: number | null): string {
|
||||
if (questionsLeft === null) return "Preguntas ilimitadas";
|
||||
if (questionsLeft === 0) return "Sin preguntas restantes";
|
||||
return `${questionsLeft} pregunta${questionsLeft !== 1 ? "s" : ""} restante${questionsLeft !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
async function submitRedsysForm(data: {
|
||||
@@ -74,6 +77,23 @@ async function submitRedsysForm(data: {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<div className="pregunta__footer">
|
||||
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
||||
<p>
|
||||
por{" "}
|
||||
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
|
||||
Cloud Host
|
||||
</a>
|
||||
{" "}— La web simplificada, la nube gestionada
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
function App() {
|
||||
@@ -134,6 +154,7 @@ function App() {
|
||||
username?: string;
|
||||
expiresAt?: number;
|
||||
tier?: string;
|
||||
questionsLeft?: number | null;
|
||||
};
|
||||
if (data.found && data.status === "active" && data.token && data.expiresAt) {
|
||||
const newCredit: CreditInfo = {
|
||||
@@ -141,6 +162,7 @@ function App() {
|
||||
username: data.username ?? "",
|
||||
expiresAt: data.expiresAt,
|
||||
tier: data.tier ?? "",
|
||||
questionsLeft: data.questionsLeft ?? null,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
|
||||
setCredit(newCredit);
|
||||
@@ -193,10 +215,15 @@ function App() {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setCredit(null);
|
||||
throw new Error("Tu acceso ha expirado. Compra un nuevo plan.");
|
||||
throw new Error("Tu acceso ha expirado o se han agotado las preguntas.");
|
||||
}
|
||||
throw new Error(msg || `Error ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
|
||||
// Update questionsLeft in state and localStorage
|
||||
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
setCredit(updated);
|
||||
setText("");
|
||||
setSent(true);
|
||||
setTimeout(() => setSent(false), 4000);
|
||||
@@ -238,6 +265,7 @@ function App() {
|
||||
<div className="pregunta__links">
|
||||
<a href="/">Volver al juego</a>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -263,6 +291,7 @@ function App() {
|
||||
) : (
|
||||
<div className="pregunta__spinner" />
|
||||
)}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -271,7 +300,7 @@ function App() {
|
||||
// ── Active credit — question form ─────────────────────────────────────────
|
||||
|
||||
if (credit) {
|
||||
const days = daysLeft(credit.expiresAt);
|
||||
const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0;
|
||||
return (
|
||||
<div className="pregunta">
|
||||
<div className="pregunta__panel">
|
||||
@@ -279,17 +308,17 @@ function App() {
|
||||
<a href="/" className="pregunta__logo">
|
||||
<img src="/assets/logo.svg" alt="argument.es" />
|
||||
</a>
|
||||
<div className="pregunta__credit-badge">
|
||||
{days === 0
|
||||
? "Expira hoy"
|
||||
: `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`}
|
||||
<div className={`pregunta__credit-badge ${exhausted ? "pregunta__credit-badge--empty" : ""}`}>
|
||||
{badgeText(credit.questionsLeft)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Hola, {credit.username}</h1>
|
||||
<p className="pregunta__sub">
|
||||
Acceso activo hasta el {formatDate(credit.expiresAt)}.
|
||||
Envía todas las preguntas que quieras.
|
||||
Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "}
|
||||
{exhausted
|
||||
? "Has agotado tus preguntas para este plan."
|
||||
: "Envía todas las preguntas que quieras."}
|
||||
</p>
|
||||
|
||||
{sent && (
|
||||
@@ -298,31 +327,41 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
|
||||
<label htmlFor="pregunta-text" className="pregunta__label">
|
||||
Tu pregunta (frase de completar)
|
||||
</label>
|
||||
<textarea
|
||||
id="pregunta-text"
|
||||
className="pregunta__textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder='Ejemplo: "La peor cosa que puedes encontrar en ___"'
|
||||
maxLength={200}
|
||||
required
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="pregunta__hint">{text.length}/200 · mínimo 10</div>
|
||||
{submitError && <div className="pregunta__error">{submitError}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="pregunta__submit"
|
||||
disabled={submitting || text.trim().length < 10}
|
||||
>
|
||||
{submitting ? "Enviando…" : "Enviar pregunta"}
|
||||
</button>
|
||||
</form>
|
||||
{!exhausted && (
|
||||
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
|
||||
<label htmlFor="pregunta-text" className="pregunta__label">
|
||||
Tu pregunta (frase de completar)
|
||||
</label>
|
||||
<textarea
|
||||
id="pregunta-text"
|
||||
className="pregunta__textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder='Ejemplo: "La peor cosa que puedes encontrar en ___"'
|
||||
maxLength={200}
|
||||
required
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="pregunta__hint">{text.length}/200 · mínimo 10</div>
|
||||
{submitError && <div className="pregunta__error">{submitError}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="pregunta__submit"
|
||||
disabled={submitting || text.trim().length < 10}
|
||||
>
|
||||
{submitting ? "Enviando…" : "Enviar pregunta"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{exhausted && (
|
||||
<a href="/pregunta" className="pregunta__btn" onClick={() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}}>
|
||||
Comprar nuevo plan
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="pregunta__links">
|
||||
<a href="/">Ver el juego</a>
|
||||
@@ -337,6 +376,7 @@ function App() {
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -355,8 +395,8 @@ function App() {
|
||||
|
||||
<h1>Propón preguntas al juego</h1>
|
||||
<p className="pregunta__sub">
|
||||
Compra acceso por tiempo y envía todas las preguntas que quieras.
|
||||
Las mejores preguntas se usan en lugar de las generadas por IA y
|
||||
Compra acceso por 30 días y envía preguntas ilimitadas o un paquete.
|
||||
Las mejores se usan en lugar de las generadas por IA y
|
||||
aparecerás en el marcador de Jugadores.
|
||||
</p>
|
||||
|
||||
@@ -370,6 +410,7 @@ function App() {
|
||||
>
|
||||
<div className="tier-card__price">{tier.price}</div>
|
||||
<div className="tier-card__label">{tier.label}</div>
|
||||
<div className="tier-card__sub">{tier.sublabel}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -406,6 +447,7 @@ function App() {
|
||||
<div className="pregunta__links">
|
||||
<a href="/">Volver al juego</a>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user