web-next: migrar toda la UI al tema real nw-ryu

Reescritura completa del frontend Next.js del sistema visual Tailwind
"simulado" al tema Django original (nw-ryu), para paridad pixel con la
web actual antes del cutover.

- Tema real: copia de static/nw-themes/nw-ryu + favicons a public/, el
  layout carga el novavow-style.css real de ultimo (gana la cascada sobre
  Tailwind) + Font Awesome.
- Shell replicando los partials Django: SiteHeader, Video, Social, Footer,
  ServerClock; home con estructura real (main-page/middle-content/...).
- Helpers reutilizables: PageShell (main-page > middle-content > body-content
  > title-content) y ServiceBox (title-box-content + back-to-account).
- Paginas migradas a clases reales del tema (fieldset/tool-button/char-box/
  item-box/info-box-light/max-center-table/alert-message/botones reales),
  eliminando el markup Tailwind (.nw-btn/.nw-card/.nw-input):
  auth (login/register/recover/reset/select-account/activate),
  cuenta + servicios de personaje (revive/unstuck/rename/customize/
  change-race/change-faction/level-up/gold/transfer + pago Stripe),
  ajustes (change-password/change-email/security-token),
  comunidad (vote-points/recruit/battlepay), foro completo, y
  admin (indice + 7 secciones + los Admin*Manager).
- Se conserva el bilingue (next-intl); claves nuevas en messages/es|en.json.

Verificado: typecheck + build OK; rutas protegidas 307->login; sin
MISSING_MESSAGE; cero Tailwind residual (solo .nw-tool-btn/.nw-page,
clases propias en globals.css).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 09:54:58 +00:00
parent 8bb18838ff
commit cc158d3819
230 changed files with 7899 additions and 1386 deletions
+60 -59
View File
@@ -42,75 +42,76 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
}
}
const field = 'nw-input'
return (
<div className="space-y-6">
<div>
{/* Alta de categoría */}
<form
onSubmit={(e) => {
e.preventDefault()
if (catForm.name.trim()) call('/api/admin/forum/category', 'POST', { name: catForm.name, order: Number(catForm.order) }).then((ok) => ok && setCatForm({ name: '', order: '0' }))
}}
className="flex flex-wrap items-end gap-2 nw-card"
className="admin-form info-box-light separate2"
>
<label className="flex-1 text-sm">
<span className="mb-1 block text-nw-muted">{t('newCategory')}</span>
<input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} className={field} />
</label>
<label className="w-20 text-sm">
<span className="mb-1 block text-nw-muted">{t('order')}</span>
<input type="number" value={catForm.order} onChange={(e) => setCatForm({ ...catForm, order: e.target.value })} className={field} />
</label>
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">{t('create')}</button>
<span className="second-brown">{t('newCategory')}</span>
<input value={catForm.name} onChange={(e) => setCatForm({ ...catForm, name: e.target.value })} placeholder={t('categoryName')} />
<span className="second-brown">{t('order')}</span>
<input type="number" value={catForm.order} onChange={(e) => setCatForm({ ...catForm, order: e.target.value })} />
<button type="submit" disabled={busy}>{t('create')}</button>
</form>
<br />
{initial.length === 0 && <p className="text-amber-200/70">{t('noCategories')}</p>}
{initial.length === 0 && <p className="second-brown centered">{t('noCategories')}</p>}
{initial.map(({ category, forums }) => (
<section key={category.id} className="nw-card">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-nw-gold-light">
{category.name} <span className="text-xs text-nw-muted">#{category.order}</span>
</h2>
<button
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/category/${category.id}`, 'DELETE')}
disabled={busy}
className="rounded border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-60"
>
{t('delete')}
</button>
<div key={category.id} className="inline-div" style={{ display: 'block' }}>
<div className="title-content-h3">
<h3 className="big-font">
{category.name} <span className="third-brown small-font">#{category.order}</span>{' '}
<button
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/category/${category.id}`, 'DELETE')}
disabled={busy}
className="nw-tool-btn red-info2"
>
{t('delete')}
</button>
</h3>
</div>
{/* Foros de la categoría */}
<ul className="mb-3 divide-y divide-amber-900/40">
{forums.length === 0 && <li className="py-2 text-sm text-nw-muted">{t('noForums')}</li>}
{forums.map((f) => (
<li key={f.id} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<p className="font-medium text-amber-300">
{f.name} {f.visibility === 0 && <span className="text-xs text-red-400">({t('hidden')})</span>}
</p>
{f.description && <p className="truncate text-xs text-nw-muted">{f.description}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => call(`/api/admin/forum/${f.id}`, 'PATCH', { visibility: f.visibility === 0 })}
disabled={busy}
className="nw-btn-ghost text-xs disabled:opacity-60"
>
{f.visibility === 0 ? t('show') : t('hide')}
</button>
<button
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')}
disabled={busy}
className="rounded border border-red-900/60 px-2 py-1 text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-60"
>
{t('delete')}
</button>
</div>
</li>
))}
</ul>
<table className="max-center-table">
<tbody>
{forums.length === 0 && (
<tr>
<td className="second-brown centered">{t('noForums')}</td>
</tr>
)}
{forums.map((f) => (
<tr key={f.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{f.name}</span>{' '}
{f.visibility === 0 && <span className="red-info2 small-font">({t('hidden')})</span>}
{f.description && <p className="third-brown small-font">{f.description}</p>}
</td>
<td className="real-info-box no-wrap-td">
<button
onClick={() => call(`/api/admin/forum/${f.id}`, 'PATCH', { visibility: f.visibility === 0 })}
disabled={busy}
className="nw-tool-btn"
>
{f.visibility === 0 ? t('show') : t('hide')}
</button>{' '}
<button
onClick={() => confirm(t('confirmDelete')) && call(`/api/admin/forum/${f.id}`, 'DELETE')}
disabled={busy}
className="nw-tool-btn red-info2"
>
{t('delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Alta de foro en esta categoría */}
<form
@@ -122,13 +123,13 @@ export function AdminForumManager({ initial }: { initial: Group[] }) {
(ok) => ok && setFF(category.id, { name: '', description: '', order: '0' }),
)
}}
className="grid gap-2 rounded-lg border border-nw-border/50 bg-nw-panel-2/30 p-3 sm:grid-cols-[1fr_1fr_auto]"
className="admin-form separate"
>
<input value={ff(category.id).name} onChange={(e) => setFF(category.id, { name: e.target.value })} placeholder={t('forumName')} maxLength={45} className={field} />
<input value={ff(category.id).description} onChange={(e) => setFF(category.id, { description: e.target.value })} placeholder={t('forumDescription')} className={field} />
<button type="submit" disabled={busy} className="nw-btn text-sm disabled:opacity-60">{t('addForum')}</button>
<input value={ff(category.id).name} onChange={(e) => setFF(category.id, { name: e.target.value })} placeholder={t('forumName')} maxLength={45} />
<input value={ff(category.id).description} onChange={(e) => setFF(category.id, { description: e.target.value })} placeholder={t('forumDescription')} />
<button type="submit" disabled={busy}>{t('addForum')}</button>
</form>
</section>
</div>
))}
</div>
)
+25 -25
View File
@@ -45,38 +45,38 @@ export function AdminGoldManager({ initial }: { initial: GoldOption[] }) {
if ((await res.json()).success) setOpts(opts.filter((o) => o.id !== id))
}
const field = 'nw-input'
return (
<div>
<form onSubmit={create} className="mb-8 grid gap-3 nw-card sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('goldAmount')}</span>
<input type="number" min="1" value={form.goldAmount} onChange={(e) => upd('goldAmount', e.target.value)} placeholder="10000" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('goldPrice')}</span>
<input type="number" min="0" step="0.01" value={form.price} onChange={(e) => upd('price', e.target.value)} placeholder="4.99" className={field} />
</label>
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
<form onSubmit={create} className="admin-form info-box-light separate2">
<span className="second-brown">{t('goldAmount')}</span>
<input type="number" min="1" value={form.goldAmount} onChange={(e) => upd('goldAmount', e.target.value)} placeholder="10000" />
<span className="second-brown">{t('goldPrice')}</span>
<input type="number" min="0" step="0.01" value={form.price} onChange={(e) => upd('price', e.target.value)} placeholder="4.99" />
<button type="submit" disabled={busy}>
{busy ? t('creating') : t('create')}
</button>
</form>
<br />
{opts.length === 0 ? (
<p className="text-amber-200/70">{t('noGold')}</p>
<p className="second-brown centered">{t('noGold')}</p>
) : (
<ul className="divide-y divide-amber-900/40">
{opts.map((o) => (
<li key={o.id} className="flex items-center justify-between gap-4 py-3">
<div>
<p className="font-semibold text-amber-300">{o.gold_amount.toLocaleString()} {t('goldUnit')}</p>
<p className="text-xs text-amber-200/50">{o.price.toFixed(2)} </p>
</div>
<button onClick={() => remove(o.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
{t('delete')}
</button>
</li>
))}
</ul>
<table className="max-center-table">
<tbody>
{opts.map((o) => (
<tr key={o.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{o.gold_amount.toLocaleString()} {t('goldUnit')}</span>
<p className="third-brown small-font">{o.price.toFixed(2)} </p>
</td>
<td className="real-info-box">
<button onClick={() => remove(o.id)} className="nw-tool-btn red-info2">
{t('delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
+27 -22
View File
@@ -47,36 +47,41 @@ export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) {
if (data.success) setNews(news.filter((n) => n.id !== id))
}
const field = 'nw-input'
return (
<div>
<form onSubmit={create} className="mb-8 space-y-3 nw-card">
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} className={field} />
<textarea value={contenido} onChange={(e) => setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} className={field} />
<input value={enlace} onChange={(e) => setEnlace(e.target.value)} placeholder={t('newsLink')} className={field} />
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
<form onSubmit={create} className="admin-form info-box-light separate2">
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} />
<textarea value={contenido} onChange={(e) => setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} />
<input value={enlace} onChange={(e) => setEnlace(e.target.value)} placeholder={t('newsLink')} />
<button type="submit" disabled={busy}>
{busy ? t('creating') : t('create')}
</button>
{error && <p className="text-red-400">{error}</p>}
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
<br />
{news.length === 0 ? (
<p className="text-amber-200/70">{t('noNews')}</p>
<p className="second-brown centered">{t('noNews')}</p>
) : (
<ul className="divide-y divide-amber-900/40">
{news.map((n) => (
<li key={n.id} className="flex items-center justify-between gap-4 py-3">
<div>
<p className="font-semibold text-amber-300">{n.titulo}</p>
{n.fecha && <p className="text-xs text-amber-200/50">{new Date(n.fecha).toLocaleString(locale)}</p>}
</div>
<button onClick={() => remove(n.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
{t('delete')}
</button>
</li>
))}
</ul>
<table className="max-center-table">
<tbody>
{news.map((n) => (
<tr key={n.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{n.titulo}</span>
{n.fecha && <p className="third-brown small-font">{new Date(n.fecha).toLocaleString(locale)}</p>}
</td>
<td className="real-info-box">
<button onClick={() => remove(n.id)} className="nw-tool-btn red-info2">
{t('delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
+19 -15
View File
@@ -31,32 +31,36 @@ function Row({ row }: { row: PriceRow }) {
}
return (
<li className="flex items-center justify-between gap-4 py-3">
<span className="text-amber-300">{t(`svc_${row.service}`)}</span>
<div className="flex items-center gap-2">
<tr className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{t(`svc_${row.service}`)}</span>
</td>
<td className="real-info-box no-wrap-td">
<input
type="number"
step="0.01"
min="0"
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-24 rounded border border-amber-900/60 bg-[#2c1e14] px-2 py-1 text-right"
/>
<button onClick={save} disabled={busy} className="rounded bg-amber-600 px-3 py-1 text-sm font-semibold text-[#1b120b] disabled:opacity-60">
style={{ width: 90, textAlign: 'right' }}
/>{' '}
<button onClick={save} disabled={busy} className="nw-tool-btn">
{t('save')}
</button>
{saved && <span className="text-sm text-green-400">{t('saved')}</span>}
</div>
</li>
</button>{' '}
{saved && <span className="green-info small-font">{t('saved')}</span>}
</td>
</tr>
)
}
export function AdminPricesManager({ prices }: { prices: PriceRow[] }) {
return (
<ul className="divide-y divide-amber-900/40">
{prices.map((row) => (
<Row key={row.service} row={row} />
))}
</ul>
<table className="max-center-table">
<tbody>
{prices.map((row) => (
<Row key={row.service} row={row} />
))}
</tbody>
</table>
)
}
+35 -43
View File
@@ -56,56 +56,48 @@ export function AdminRecruitManager({ initial }: { initial: RecruitReward[] }) {
if ((await res.json()).success) setRewards(rewards.filter((r) => r.id !== id))
}
const field = 'nw-input'
return (
<div>
<form onSubmit={create} className="mb-8 grid gap-3 nw-card sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('requiredFriends')}</span>
<input type="number" min="1" value={form.requiredFriends} onChange={(e) => upd('requiredFriends', e.target.value)} placeholder="1" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('rewardName')}</span>
<input value={form.rewardName} onChange={(e) => upd('rewardName', e.target.value)} placeholder="Montura épica" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('itemId')}</span>
<input type="number" min="1" value={form.itemId} onChange={(e) => upd('itemId', e.target.value)} placeholder="49284" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('itemQuantity')}</span>
<input type="number" min="1" value={form.itemQuantity} onChange={(e) => upd('itemQuantity', e.target.value)} className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('itemLink')}</span>
<input value={form.itemLink} onChange={(e) => upd('itemLink', e.target.value)} placeholder="https://…" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('iconClass')}</span>
<input value={form.iconClass} onChange={(e) => upd('iconClass', e.target.value)} placeholder="icontinyl q3" className={field} />
</label>
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60 sm:col-span-2">
<form onSubmit={create} className="admin-form info-box-light separate2">
<span className="second-brown">{t('requiredFriends')}</span>
<input type="number" min="1" value={form.requiredFriends} onChange={(e) => upd('requiredFriends', e.target.value)} placeholder="1" />
<span className="second-brown">{t('rewardName')}</span>
<input value={form.rewardName} onChange={(e) => upd('rewardName', e.target.value)} placeholder="Montura épica" />
<span className="second-brown">{t('itemId')}</span>
<input type="number" min="1" value={form.itemId} onChange={(e) => upd('itemId', e.target.value)} placeholder="49284" />
<span className="second-brown">{t('itemQuantity')}</span>
<input type="number" min="1" value={form.itemQuantity} onChange={(e) => upd('itemQuantity', e.target.value)} />
<span className="second-brown">{t('itemLink')}</span>
<input value={form.itemLink} onChange={(e) => upd('itemLink', e.target.value)} placeholder="https://…" />
<span className="second-brown">{t('iconClass')}</span>
<input value={form.iconClass} onChange={(e) => upd('iconClass', e.target.value)} placeholder="icontinyl q3" />
<button type="submit" disabled={busy}>
{busy ? t('creating') : t('create')}
</button>
</form>
<br />
{rewards.length === 0 ? (
<p className="text-amber-200/70">{t('noRewards')}</p>
<p className="second-brown centered">{t('noRewards')}</p>
) : (
<ul className="divide-y divide-amber-900/40">
{rewards.map((r) => (
<li key={r.id} className="flex items-center justify-between gap-4 py-3">
<div>
<p className="font-semibold text-amber-300">{r.reward_name}</p>
<p className="text-xs text-amber-200/50">
{t('requiredFriendsShort', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
</div>
<button onClick={() => remove(r.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
{t('delete')}
</button>
</li>
))}
</ul>
<table className="max-center-table">
<tbody>
{rewards.map((r) => (
<tr key={r.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{r.reward_name}</span>
<p className="third-brown small-font">
{t('requiredFriendsShort', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
</td>
<td className="real-info-box">
<button onClick={() => remove(r.id)} className="nw-tool-btn red-info2">
{t('delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
+36 -43
View File
@@ -56,60 +56,53 @@ export function AdminUsersManager() {
return (
<div>
<form onSubmit={search} className="mb-6 flex gap-2">
<form onSubmit={search} className="centered separate">
<input
type="search"
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('userSearchPlaceholder')}
className="nw-input flex-1"
/>
<button type="submit" disabled={busy} className="nw-btn whitespace-nowrap disabled:opacity-60">
/>{' '}
<button type="submit" disabled={busy} className="search-items-button">
{busy ? t('searching') : t('search')}
</button>
</form>
{searched && accounts.length === 0 ? (
<p className="text-amber-200/70">{t('noUsers')}</p>
<p className="second-brown centered">{t('noUsers')}</p>
) : (
<ul className="divide-y divide-amber-900/40">
{accounts.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="flex items-center gap-2 font-semibold text-amber-300">
{a.username}
{a.gmlevel > 0 && (
<span className="rounded bg-nw-gold/15 px-1.5 py-0.5 text-xs text-nw-gold-light">GM {a.gmlevel}</span>
<table className="max-center-table">
<tbody>
{accounts.map((a) => (
<tr key={a.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{a.username}</span>{' '}
{a.gmlevel > 0 && <span className="second-yellow small-font">[GM {a.gmlevel}]</span>}{' '}
{a.online && <span className="green-info small-font"> {t('online')}</span>}{' '}
{a.banned && <span className="red-info2 small-font"> {t('banned')}</span>}
<p className="third-brown small-font">
#{a.id}
{a.email && ` · ${a.email}`}
{a.last_login && ` · ${t('lastLogin')}: ${new Date(a.last_login).toLocaleDateString()}`}
{a.banned && a.ban_reason && ` · ${a.ban_reason}`}
{a.banned && a.ban_until && ` (${new Date(a.ban_until).toLocaleDateString()})`}
</p>
</td>
<td className="real-info-box">
{a.banned ? (
<button onClick={() => act(a, 'unban')} className="nw-tool-btn green-info">
{t('unban')}
</button>
) : (
<button onClick={() => act(a, 'ban')} className="nw-tool-btn red-info2">
{t('ban')}
</button>
)}
{a.online && <span className="text-xs text-green-400"> {t('online')}</span>}
{a.banned && <span className="text-xs text-red-400"> {t('banned')}</span>}
</p>
<p className="truncate text-xs text-amber-200/50">
#{a.id}
{a.email && ` · ${a.email}`}
{a.last_login && ` · ${t('lastLogin')}: ${new Date(a.last_login).toLocaleDateString()}`}
{a.banned && a.ban_reason && ` · ${a.ban_reason}`}
{a.banned && a.ban_until && ` (${new Date(a.ban_until).toLocaleDateString()})`}
</p>
</div>
{a.banned ? (
<button
onClick={() => act(a, 'unban')}
className="rounded border border-green-900/60 px-3 py-1 text-sm text-green-400 hover:bg-green-950/40"
>
{t('unban')}
</button>
) : (
<button
onClick={() => act(a, 'ban')}
className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40"
>
{t('ban')}
</button>
)}
</li>
))}
</ul>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
+27 -23
View File
@@ -44,36 +44,40 @@ export function AdminVotesManager({ initial }: { initial: VoteSite[] }) {
if ((await res.json()).success) setSites(sites.filter((s) => s.id !== id))
}
const field = 'nw-input'
return (
<div>
<form onSubmit={create} className="mb-8 space-y-3 nw-card">
<input value={form.name} onChange={(e) => upd('name', e.target.value)} placeholder={t('voteName')} className={field} />
<input value={form.url} onChange={(e) => upd('url', e.target.value)} placeholder={t('voteUrl')} className={field} />
<input value={form.imageUrl} onChange={(e) => upd('imageUrl', e.target.value)} placeholder={t('voteImage')} className={field} />
<input type="number" min="0" value={form.points} onChange={(e) => upd('points', e.target.value)} placeholder={t('votePoints')} className={field} />
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
<form onSubmit={create} className="admin-form info-box-light separate2">
<input value={form.name} onChange={(e) => upd('name', e.target.value)} placeholder={t('voteName')} />
<input value={form.url} onChange={(e) => upd('url', e.target.value)} placeholder={t('voteUrl')} />
<input value={form.imageUrl} onChange={(e) => upd('imageUrl', e.target.value)} placeholder={t('voteImage')} />
<input type="number" min="0" value={form.points} onChange={(e) => upd('points', e.target.value)} placeholder={t('votePoints')} />
<button type="submit" disabled={busy}>
{busy ? t('creating') : t('create')}
</button>
</form>
<br />
{sites.length === 0 ? (
<p className="text-amber-200/70">{t('noVotes')}</p>
<p className="second-brown centered">{t('noVotes')}</p>
) : (
<ul className="divide-y divide-amber-900/40">
{sites.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-4 py-3">
<div>
<p className="font-semibold text-amber-300">{s.name}</p>
<p className="text-xs text-amber-200/50">
{s.points} PV · {s.url}
</p>
</div>
<button onClick={() => remove(s.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
{t('delete')}
</button>
</li>
))}
</ul>
<table className="max-center-table">
<tbody>
{sites.map((s) => (
<tr key={s.id} className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{s.name}</span>
<p className="third-brown small-font">
{s.points} PV · {s.url}
</p>
</td>
<td className="real-info-box">
<button onClick={() => remove(s.id)} className="nw-tool-btn red-info2">
{t('delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
+28 -26
View File
@@ -33,35 +33,37 @@ export function BattlepayList({ orders }: { orders: BattlepayOrder[] }) {
}
}
if (orders.length === 0) return <p className="text-amber-200/70">{t('noOrders')}</p>
if (orders.length === 0) return <p className="second-brown">{t('noOrders')}</p>
return (
<div>
{error && <p className="mb-4 text-red-400">{error}</p>}
<ul className="space-y-3">
{orders.map((o) => (
<li key={o.id} className="flex flex-wrap items-center justify-between gap-3 nw-card">
<div>
<p className="font-semibold text-amber-300">{o.product_name}</p>
<p className="text-xs text-amber-200/50">
{t('reference')}: {o.reference}
{o.created_at ? ` · ${new Date(o.created_at * 1000).toLocaleDateString()}` : ''}
</p>
</div>
<div className="flex items-center gap-3">
<span className="font-semibold text-nw-gold-light">{o.price_eur.toFixed(2)} </span>
<button
type="button"
onClick={() => pay(o.reference)}
disabled={busy !== null}
className="nw-btn text-sm disabled:opacity-60"
>
{busy === o.reference ? t('redirecting') : t('pay')}
</button>
</div>
</li>
))}
</ul>
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
<table className="max-center-table">
<tbody>
{orders.map((o) => (
<tr key={o.id} className="info-box-light">
<td className="separate">
<span className="yellow-info">{o.product_name}</span>
<br />
<span className="third-brown small-font">
{t('reference')}: {o.reference}
{o.created_at ? ` · ${new Date(o.created_at * 1000).toLocaleDateString()}` : ''}
</span>
</td>
<td className="real-info-box">
<span className="yellow-info">{o.price_eur.toFixed(2)} </span>
</td>
<td className="real-info-box">
<button type="button" onClick={() => pay(o.reference)} disabled={busy !== null}>
{busy === o.reference ? t('redirecting') : t('pay')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+16 -16
View File
@@ -9,10 +9,11 @@ interface Props {
characters: string[]
endpoint: string
actionLabel: string
buttonClass?: string
}
/** Formulario reutilizable para acciones de personaje por SOAP (revive, unstuck...). */
export function CharacterActionForm({ characters, endpoint, actionLabel }: Props) {
export function CharacterActionForm({ characters, endpoint, actionLabel, buttonClass = '' }: Props) {
const t = useTranslations('Services')
const [character, setCharacter] = useState('')
const [busy, setBusy] = useState(false)
@@ -44,18 +45,15 @@ export function CharacterActionForm({ characters, endpoint, actionLabel }: Props
}
if (characters.length === 0) {
return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
return <p className="second-brown">{t('noCharactersYet')}</p>
}
return (
<div className="mx-auto max-w-sm text-center">
<form onSubmit={handleSubmit} className="space-y-3">
<select
value={character}
onChange={(e) => setCharacter(e.target.value)}
required
className="nw-input"
>
<div className="centered">
<p>{t('choose')}</p>
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>
{t('selectCharacter')}
</option>
@@ -65,15 +63,17 @@ export function CharacterActionForm({ characters, endpoint, actionLabel }: Props
</option>
))}
</select>
<button
type="submit"
disabled={busy || !character}
className="w-full nw-btn disabled:opacity-60"
>
<br />
<button type="submit" className={buttonClass} disabled={busy || !character}>
{busy ? t('processing') : actionLabel}
</button>
</form>
{message && <p className={`mt-3 ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
<hr />
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && (
<span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>
)}
</div>
</div>
)
}
+5 -7
View File
@@ -38,21 +38,19 @@ export function ConfirmClient({
}, [hash, endpoint])
return (
<div className="text-center">
<div className="centered">
{state === 'loading' && <p>{t('activating')}</p>}
{state === 'ok' && (
<>
<p className="text-green-400">{t('success')}</p>
<span className="ok-form-response">{t('success')}</span>
{showLogin && (
<p className="mt-4">
<Link href="/login" className="text-sky-400 underline">
{t('goLogin')}
</Link>
<p style={{ marginTop: 16 }}>
<Link href="/login">{t('goLogin')}</Link>
</p>
)}
</>
)}
{state === 'error' && <p className="text-red-400">{t('error')}</p>}
{state === 'error' && <span className="red-form-response">{t('error')}</span>}
</div>
)
}
+38 -70
View File
@@ -1,76 +1,44 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
const SERVER_NAME = 'NovaWoW'
const SOCIALS: { name: string; href: string; path: string }[] = [
{
name: 'Discord',
href: 'https://discord.gg/novawow',
path: 'M20.317 4.369a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.211.375-.445.865-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.6 12.6 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.009c.12.099.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.056c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028ZM8.02 15.331c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.332-.956 2.418-2.157 2.418Zm7.975 0c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.332-.946 2.418-2.157 2.418Z',
},
{
name: 'YouTube',
href: 'https://www.youtube.com/channel/UCaHL8BZcho8AkeM9wckQbyg',
path: 'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z',
},
{
name: 'Facebook',
href: 'https://www.facebook.com/NovaWoW/',
path: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073Z',
},
{
name: 'Instagram',
href: 'https://www.instagram.com/NovaWoW/',
path: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069ZM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0Zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324ZM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881Z',
},
]
/**
* Pie de página — réplica del partial Django `partials/footer.html`
* (enlaces legales + copyright + crédito de diseño) con las clases del tema real.
*/
export function Footer() {
const year = new Date().getFullYear()
export async function Footer() {
const t = await getTranslations('Nav')
return (
<footer className="mt-16 border-t border-nw-border/60 bg-nw-bg/60">
<div className="mx-auto flex max-w-5xl flex-col items-center gap-5 px-4 py-8 text-sm">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between sm:self-stretch">
<div className="flex items-center gap-2 text-nw-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/brand/logo.webp" alt="Nova WoW" className="h-7 w-auto opacity-80" />
<span>© 2026 Nova WoW · WotLK Classic 3.4.3</span>
</div>
<nav className="flex flex-wrap items-center justify-center gap-4 text-nw-muted">
<Link href="/" className="hover:text-nw-gold-light">
{t('home')}
</Link>
<Link href="/forum" className="hover:text-nw-gold-light">
{t('forum')}
</Link>
<Link href="/vote-points" className="hover:text-nw-gold-light">
{t('vote')}
</Link>
<Link href="/recruit" className="hover:text-nw-gold-light">
{t('recruit')}
</Link>
<Link href="/register" className="hover:text-nw-gold-light">
{t('register')}
</Link>
</nav>
</div>
<div className="flex items-center gap-5">
{SOCIALS.map((s) => (
<a
key={s.name}
href={s.href}
target="_blank"
rel="noopener noreferrer"
aria-label={s.name}
title={s.name}
className="text-nw-muted transition-colors hover:text-nw-gold-light"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5" aria-hidden="true">
<path d={s.path} />
</svg>
</a>
))}
</div>
</div>
<footer>
<p>
<a href="/terms-and-conditions">Términos y Condiciones</a>
<span className="third-brown">
&nbsp;&nbsp; <i className="fas fa-grip-lines-vertical"></i>&nbsp;&nbsp;{' '}
</span>
<a href="/privacy-policy">Política de Privacidad</a>
<span className="third-brown">
&nbsp;&nbsp; <i className="fas fa-grip-lines-vertical"></i>&nbsp;&nbsp;{' '}
</span>
<a href="/refund-policy">Política de Reembolso</a>
<span className="third-brown">
&nbsp;&nbsp; <i className="fas fa-grip-lines-vertical"></i>&nbsp;&nbsp;{' '}
</span>
<a href="/cookies">Declaración de Cookies</a>
<span className="third-brown">
&nbsp;&nbsp; <i className="fas fa-grip-lines-vertical"></i>&nbsp;&nbsp;{' '}
</span>
<a href="/legal-notice">Aviso legal</a>
<span className="third-brown">
&nbsp;&nbsp; <i className="fas fa-grip-lines-vertical"></i>&nbsp;&nbsp;{' '}
</span>
<a href="/contact-us">Contáctanos</a>
</p>
<br />
<p className="third-brown">
© Copyright {SERVER_NAME} {year}. Todos los derechos reservados
</p>
<p className="third-brown">
Diseño: &quot;{SERVER_NAME}&quot; Por Inna Hoover Brown <i className="fas fa-cat"></i>
</p>
</footer>
)
}
+4 -5
View File
@@ -17,15 +17,14 @@ export function ForumSearchBox({ initial = '' }: { initial?: string }) {
}
return (
<form onSubmit={submit} className="mb-6 flex gap-2">
<form onSubmit={submit} className="centered separate">
<input
type="search"
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('searchPlaceholder')}
className="nw-input flex-1"
/>
<button type="submit" className="nw-btn whitespace-nowrap">
/>{' '}
<button type="submit" className="search-items-button">
{t('search')}
</button>
</form>
+12 -8
View File
@@ -36,17 +36,17 @@ export function GoldForm({ characters, options }: { characters: string[]; option
}
}
const field = 'nw-input'
if (characters.length === 0) return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
if (characters.length === 0) return <p className="second-brown">{t('noCharactersYet')}</p>
return (
<div className="mx-auto max-w-sm text-center">
<form onSubmit={handleSubmit} className="space-y-3">
<select value={character} onChange={(e) => setCharacter(e.target.value)} required className={field}>
<div className="centered">
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>{t('selectCharacter')}</option>
{characters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<select value={amount} onChange={(e) => setAmount(e.target.value)} required className={field}>
<br />
<select value={amount} onChange={(e) => setAmount(e.target.value)} required>
<option value="" disabled>{tp('gold.selectAmount')}</option>
{options.map((o) => (
<option key={o.gold_amount} value={o.gold_amount}>
@@ -54,11 +54,15 @@ export function GoldForm({ characters, options }: { characters: string[]; option
</option>
))}
</select>
<button type="submit" disabled={busy || !character || !amount} className="w-full nw-btn disabled:opacity-60">
<br />
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
{busy ? t('processing') : tp('gold.buy')}
</button>
</form>
{error && <p className="mt-3 text-red-400">{error}</p>}
<hr />
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</div>
)
}
+4 -51
View File
@@ -1,58 +1,11 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { LanguageSwitcher } from './LanguageSwitcher'
import { LogoutButton } from './LogoutButton'
import { SiteHeader } from './SiteHeader'
export async function Header() {
const t = await getTranslations('Nav')
const session = await getSession()
const loggedIn = Boolean(session.username)
// Django muestra bnet_email si existe, si no el username.
const accountLabel = session.bnetEmail || session.username || ''
return (
<header className="sticky top-0 z-50 border-b border-nw-border/60 bg-nw-bg/80 backdrop-blur supports-[backdrop-filter]:bg-nw-bg/60">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
<Link href="/" className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/brand/logo.webp" alt="Nova WoW" className="h-9 w-auto" />
<span className="nw-heading text-xl">Nova WoW</span>
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/" className="text-amber-200 hover:text-amber-400">
{t('home')}
</Link>
<Link href="/forum" className="text-amber-200 hover:text-amber-400">
{t('forum')}
</Link>
<Link href="/vote-points" className="text-amber-200 hover:text-amber-400">
{t('vote')}
</Link>
{loggedIn ? (
<>
<Link href="/battlepay" className="text-amber-200 hover:text-amber-400">
{t('store')}
</Link>
<Link href="/account" className="text-amber-200 hover:text-amber-400">
{t('account')}
</Link>
<LogoutButton />
</>
) : (
<>
<Link href="/login" className="text-amber-200 hover:text-amber-400">
{t('login')}
</Link>
<Link
href="/register"
className="rounded bg-amber-600 px-3 py-1 font-semibold text-[#1b120b] hover:bg-amber-500"
>
{t('register')}
</Link>
</>
)}
<LanguageSwitcher />
</nav>
</div>
</header>
)
return <SiteHeader loggedIn={loggedIn} accountLabel={accountLabel} />
}
+13 -6
View File
@@ -37,16 +37,23 @@ export function NewTopicForm({ forumId }: { forumId: number }) {
}
}
const field = 'nw-input'
return (
<form onSubmit={handleSubmit} className="mt-8 space-y-3 nw-card">
<h3 className="font-semibold text-amber-400">{t('newTopic')}</h3>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('newTopicName')} maxLength={200} className={field} />
<form onSubmit={handleSubmit} className="info-box-light separate2">
<div className="title-content-h3">
<h3 className="big-font">{t('newTopic')}</h3>
</div>
<br />
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('newTopicName')} maxLength={200} style={{ width: '100%' }} />
<br />
<br />
<RichTextArea value={text} onChange={setText} placeholder={t('newTopicText')} rows={4} />
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
<br />
<button type="submit" disabled={busy}>
{busy ? t('publishing') : t('publish')}
</button>
{error && <p className="text-red-400">{error}</p>}
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
)
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Envoltorio de página con la estructura EXACTA del tema Django:
* main-page > middle-content > body-content > title-content <h1>.
* Los hijos aportan los bloques `box-content` / `body-box-content`.
*/
export function PageShell({
title,
children,
}: {
title: React.ReactNode
children: React.ReactNode
}) {
return (
<div className="main-page">
<div className="middle-content">
<div className="body-content">
<div className="title-content">
<h1>{title}</h1>
</div>
{children}
</div>
</div>
</div>
)
}
+8 -13
View File
@@ -23,26 +23,21 @@ export async function Pagination({
const pages: number[] = []
for (let p = from; p <= to; p++) pages.push(p)
const cls = (active: boolean) =>
`inline-flex min-w-9 items-center justify-center rounded border px-2.5 py-1 text-sm ${
active
? 'border-nw-gold bg-nw-gold/15 font-semibold text-nw-gold-light'
: 'border-nw-border/60 text-nw-muted hover:border-nw-gold/60 hover:text-nw-gold-light'
}`
const cls = (active: boolean) => `nw-page-link${active ? ' nw-page-current' : ''}`
return (
<nav className="mt-6 flex flex-wrap items-center justify-center gap-1" aria-label="pagination">
<nav className="centered separate" aria-label="pagination">
{page > 1 && (
<Link href={hrefFor(page - 1)} className={cls(false)}>
<Link href={hrefFor(page - 1)} className="nw-page-link">
{t('prev')}
</Link>
)}
{from > 1 && (
<>
<Link href={hrefFor(1)} className={cls(false)}>
<Link href={hrefFor(1)} className="nw-page-link">
1
</Link>
{from > 2 && <span className="px-1 text-nw-muted"></span>}
{from > 2 && <span className="third-brown"></span>}
</>
)}
{pages.map((p) => (
@@ -52,14 +47,14 @@ export async function Pagination({
))}
{to < totalPages && (
<>
{to < totalPages - 1 && <span className="px-1 text-nw-muted"></span>}
<Link href={hrefFor(totalPages)} className={cls(false)}>
{to < totalPages - 1 && <span className="third-brown"></span>}
<Link href={hrefFor(totalPages)} className="nw-page-link">
{totalPages}
</Link>
</>
)}
{page < totalPages && (
<Link href={hrefFor(page + 1)} className={cls(false)}>
<Link href={hrefFor(page + 1)} className="nw-page-link">
{t('next')}
</Link>
)}
+14 -16
View File
@@ -7,10 +7,11 @@ interface Props {
characters: string[]
checkoutEndpoint: string
payLabel: string // ya formateado con el precio
buttonClass?: string
}
/** Selector de personaje + botón de pago. Redirige al Checkout de Stripe. */
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Props) {
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, buttonClass = '' }: Props) {
const t = useTranslations('Services')
const [character, setCharacter] = useState('')
const [busy, setBusy] = useState(false)
@@ -42,18 +43,15 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Prop
}
if (characters.length === 0) {
return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
return <p className="second-brown">{t('noCharactersYet')}</p>
}
return (
<div className="mx-auto max-w-sm text-center">
<form onSubmit={handleSubmit} className="space-y-3">
<select
value={character}
onChange={(e) => setCharacter(e.target.value)}
required
className="nw-input"
>
<div className="centered">
<p>{t('choose')}</p>
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>
{t('selectCharacter')}
</option>
@@ -63,15 +61,15 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel }: Prop
</option>
))}
</select>
<button
type="submit"
disabled={busy || !character}
className="w-full nw-btn disabled:opacity-60"
>
<br />
<button type="submit" className={buttonClass} disabled={busy || !character}>
{busy ? t('processing') : payLabel}
</button>
</form>
{error && <p className="mt-3 text-red-400">{error}</p>}
<hr />
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</div>
)
}
+23 -23
View File
@@ -89,45 +89,45 @@ export function PostActions({
if (editing) {
return (
<div className="mt-3 space-y-2">
<div className="separate">
<RichTextArea value={text} onChange={setText} rows={4} />
<div className="flex gap-2">
<button type="button" onClick={save} disabled={busy} className="nw-btn text-sm disabled:opacity-60">
{busy ? t('sending') : t('save')}
</button>
<button
type="button"
onClick={() => { setEditing(false); setText(initialText); setError(null) }}
className="nw-btn-ghost text-sm"
>
{t('cancel')}
</button>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<br />
<button type="button" onClick={save} disabled={busy} className="nw-tool-btn">
{busy ? t('sending') : t('save')}
</button>{' '}
<button
type="button"
onClick={() => { setEditing(false); setText(initialText); setError(null) }}
className="nw-tool-btn"
>
{t('cancel')}
</button>
{error && <p className="red-info2 small-font">{error}</p>}
</div>
)
}
if (deleted) {
return (
<div className="mt-2 flex items-center gap-3 text-xs">
<button type="button" onClick={restore} disabled={busy} className="text-green-400 hover:text-green-300 disabled:opacity-60">
<div className="separate small-font">
<button type="button" onClick={restore} disabled={busy} className="nw-tool-btn green-info">
{t('restore')}
</button>
{error && <span className="text-red-400">{error}</span>}
{error && <span className="red-info2"> {error}</span>}
</div>
)
}
return (
<div className="mt-2 flex items-center gap-3 text-xs">
<button type="button" onClick={() => setEditing(true)} className="text-nw-muted hover:text-nw-gold-light">
<div className="separate small-font">
<a href="javascript:void(0);" onClick={() => setEditing(true)} className="second-brown">
{t('edit')}
</button>
<button type="button" onClick={remove} disabled={busy} className="text-nw-muted hover:text-red-400 disabled:opacity-60">
</a>
{' '}
<a href="javascript:void(0);" onClick={() => { if (!busy) remove() }} className="second-brown">
{t('delete')}
</button>
{error && <span className="text-red-400">{error}</span>}
</a>
{error && <span className="red-info2"> {error}</span>}
</div>
)
}
+35 -35
View File
@@ -43,59 +43,59 @@ export function RecruitClaim({
return (
<div>
<div className="mb-6 flex flex-wrap items-center gap-3 nw-card">
<p className="text-sm text-nw-muted">
{t('level80Friends')}: <span className="font-semibold text-nw-gold-light">{level80Count}</span>
<div className="info-box-light separate">
<p className="second-brown">
{t('level80Friends')}: <span className="yellow-info">{level80Count}</span>
</p>
{characters.length > 0 ? (
<label className="ml-auto text-sm">
<span className="mr-2 text-nw-muted">{t('deliverTo')}</span>
<select value={character} onChange={(e) => setCharacter(e.target.value)} className="nw-input inline-block w-auto">
<p>
<span className="second-brown">{t('deliverTo')} </span>
<select value={character} onChange={(e) => setCharacter(e.target.value)}>
{characters.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
</p>
) : (
<p className="ml-auto text-sm text-amber-200/60">{t('noCharacters')}</p>
<p className="second-brown">{t('noCharacters')}</p>
)}
</div>
{msg && <p className={`mb-4 ${msg.ok ? 'text-green-400' : 'text-red-400'}`}>{msg.text}</p>}
<div className="alert-message" style={{ display: msg ? 'block' : 'none' }}>
{msg && <span className={msg.ok ? 'ok-form-response' : 'red-form-response'}>{msg.text}</span>}
</div>
{rewards.length === 0 ? (
<p className="text-amber-200/70">{t('noRewards')}</p>
<p className="second-brown">{t('noRewards')}</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<div className="centered">
{rewards.map((r) => {
const eligible = level80Count >= r.required_friends
return (
<div key={r.id} className="flex flex-col justify-between nw-card">
<div>
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="font-semibold text-amber-300 hover:text-amber-400">
{r.reward_name}
</a>
<p className="mt-1 text-xs text-amber-200/60">
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
</div>
<div className="mt-3">
{r.claimed ? (
<span className="text-sm text-green-400"> {t('claimed')}</span>
) : (
<button
type="button"
onClick={() => claim(r)}
disabled={!eligible || busyId !== null || characters.length === 0}
className="nw-btn text-sm disabled:cursor-not-allowed disabled:opacity-50"
title={!eligible ? t('notEnoughFriends') : undefined}
>
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
</button>
)}
</div>
<div key={r.id} className="raf-box">
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="yellow-info">
{r.reward_name}
</a>
<p className="second-brown small-font">
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
<br />
{r.claimed ? (
<span className="green-info">
<i className="fas fa-check"></i> {t('claimed')}
</span>
) : (
<button
type="button"
onClick={() => claim(r)}
disabled={!eligible || busyId !== null || characters.length === 0}
title={!eligible ? t('notEnoughFriends') : undefined}
>
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
</button>
)}
</div>
)
})}
+6 -3
View File
@@ -39,12 +39,15 @@ export function ReplyForm({ topicId }: { topicId: number }) {
}
return (
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
<form onSubmit={handleSubmit} className="separate2">
<RichTextArea value={text} onChange={setText} placeholder={t('replyText')} rows={3} />
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60">
<br />
<button type="submit" disabled={busy}>
{busy ? t('sending') : t('sendReply')}
</button>
{error && <p className="text-red-400">{error}</p>}
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</form>
)
}
+22 -25
View File
@@ -43,44 +43,41 @@ export function RichTextArea({
surround(`<a href="${url.replace(/"/g, '&quot;')}" target="_blank">`, '</a>', t('linkText'))
}
const btn =
'rounded border border-nw-border/60 px-2 py-1 text-xs text-nw-muted hover:border-nw-gold/60 hover:text-nw-gold-light'
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
<button type="button" onClick={() => surround('<strong>', '</strong>')} className={`${btn} font-bold`} title={t('bold')}>
B
</button>
<button type="button" onClick={() => surround('<em>', '</em>')} className={`${btn} italic`} title={t('italic')}>
I
</button>
<button type="button" onClick={() => surround('<u>', '</u>')} className={`${btn} underline`} title={t('underline')}>
U
</button>
<button type="button" onClick={() => surround('<s>', '</s>')} className={`${btn} line-through`} title={t('strike')}>
S
</button>
<button type="button" onClick={insertLink} className={btn} title={t('link')}>
<div>
<div className="lefted">
<button type="button" onClick={() => surround('<strong>', '</strong>')} className="nw-tool-btn" title={t('bold')}>
<b>B</b>
</button>{' '}
<button type="button" onClick={() => surround('<em>', '</em>')} className="nw-tool-btn" title={t('italic')}>
<i>I</i>
</button>{' '}
<button type="button" onClick={() => surround('<u>', '</u>')} className="nw-tool-btn" title={t('underline')}>
<u>U</u>
</button>{' '}
<button type="button" onClick={() => surround('<s>', '</s>')} className="nw-tool-btn" title={t('strike')}>
<s>S</s>
</button>{' '}
<button type="button" onClick={insertLink} className="nw-tool-btn" title={t('link')}>
🔗
</button>
<button type="button" onClick={() => surround('<blockquote>', '</blockquote>', t('quoteText'))} className={btn} title={t('quote')}>
</button>{' '}
<button type="button" onClick={() => surround('<blockquote>', '</blockquote>', t('quoteText'))} className="nw-tool-btn" title={t('quote')}>
</button>
<button type="button" onClick={() => surround('<ul>\n<li>', '</li>\n</ul>', t('listItem'))} className={btn} title={t('list')}>
</button>{' '}
<button type="button" onClick={() => surround('<ul>\n<li>', '</li>\n</ul>', t('listItem'))} className="nw-tool-btn" title={t('list')}>
</button>
<button type="button" onClick={() => surround('<code>', '</code>')} className={`${btn} font-mono`} title={t('code')}>
</button>{' '}
<button type="button" onClick={() => surround('<code>', '</code>')} className="nw-tool-btn" title={t('code')}>
{'</>'}
</button>
</div>
<br />
<textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="nw-input"
/>
</div>
)
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { useEffect, useState } from 'react'
/** Reloj en vivo del estado del servidor (equivale a startTime()/useClock de Django). */
export function ServerClock() {
const [time, setTime] = useState('')
useEffect(() => {
const tick = () => setTime(new Date().toLocaleTimeString('es-ES'))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [])
return (
<span id="server-time" suppressHydrationWarning>
{time}
</span>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
/**
* Caja de servicio con la estructura EXACTA de Django: title-box-content con
* <h2> + enlaces "back-to-account" / "back-to-account-responsive", y el cuerpo
* en body-box-content justified. Se usa dentro de <PageShell>.
*/
export async function ServiceBox({
heading,
children,
}: {
heading?: string
children: React.ReactNode
}) {
const t = await getTranslations('Services')
return (
<div className="box-content">
<div className="title-box-content">
<h2>{heading ?? t('info')}</h2>
<Link className="back-to-account" href="/account">
{t('back')}
</Link>
<Link className="back-to-account-responsive" href="/account">
{t('backShort')}
</Link>
</div>
<div className="body-box-content justified">{children}</div>
</div>
)
}
+12 -8
View File
@@ -4,6 +4,8 @@ import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getPaidService } from '@/lib/paid-services'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
import { PaidServiceForm } from '@/components/PaidServiceForm'
/** Contenido común de una página de servicio de pago (guardas + form). */
@@ -20,13 +22,15 @@ export async function ServicePageContent({ service, locale }: { service: string;
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), cfg!.price({})])
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t(`${service}.title`)}</h1>
<PaidServiceForm
characters={chars.map((c) => c.name)}
checkoutEndpoint={`/api/character/${service}/checkout`}
payLabel={t(`${service}.pay`, { price })}
/>
</main>
<PageShell title={t(`${service}.title`)}>
<ServiceBox>
<PaidServiceForm
characters={chars.map((c) => c.name)}
checkoutEndpoint={`/api/character/${service}/checkout`}
payLabel={t(`${service}.pay`, { price })}
buttonClass={`${service}-button`}
/>
</ServiceBox>
</PageShell>
)
}
+164
View File
@@ -0,0 +1,164 @@
'use client'
import { useState } from 'react'
import { Link } from '@/i18n/navigation'
import { useRouter } from '@/i18n/navigation'
const SERVER_NAME = 'NovaWoW'
/**
* Cabecera / barra de navegación — réplica EXACTA del partial Django
* `home/templates/partials/header.html` con las clases del tema real
* (nav-wrapper, nav-bar, nav-dropdown...). Los enlaces internos que ya
* existen en web-next usan <Link> (next-intl); el resto se mantiene igual
* que en Django. El toggle móvil replica `nwNavBar()` de nw-scripts.js.
*/
export function SiteHeader({
loggedIn,
accountLabel,
}: {
loggedIn: boolean
accountLabel: string
}) {
const router = useRouter()
const [open, setOpen] = useState(false)
async function logout(e: React.MouseEvent) {
e.preventDefault()
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' })
router.push('/')
router.refresh()
}
return (
<header>
<div className="nav-wrapper">
<div className={open ? 'nav-bar responsive' : 'nav-bar'} id="nw-nav-bar">
<a
href="javascript:void(0);"
className={open ? 'icon change' : 'icon'}
id="menu-icon"
onClick={(e) => {
e.preventDefault()
setOpen((v) => !v)
}}
>
<div className="bar1"></div>
<div className="bar2"></div>
<div className="bar3"></div>
</a>
{loggedIn ? (
<Link href="/">INICIO</Link>
) : (
<Link href="/register">CREAR CUENTA</Link>
)}
<div className="nav-dropdown">
<div className="nav-dropdown-btn">
DESCARGAS <i className="fas fa-caret-down"></i>
</div>
<div className="nav-dropdown-content">
<p>
<a href="/download-client">CLIENTE</a>
</p>
<p>
<a href="/download-addons">ADDONS</a>
</p>
</div>
</div>
<div className="nav-dropdown">
<div className="nav-dropdown-btn">
{SERVER_NAME} <i className="fas fa-caret-down"></i>
</div>
<div className="nav-dropdown-content">
<p>
<a
href="https://foro.novawow.com/topic/26596-changelog-2024/"
target="_blank"
rel="noopener noreferrer"
>
CHANGELOG
</a>
</p>
<p>
<a href="/novawow-realm">REINO</a>
</p>
{loggedIn && (
<p>
<a href="/novawow-players">JUGADORES</a>
</p>
)}
</div>
</div>
<div className="nav-dropdown">
<div className="nav-dropdown-btn">
COMUNIDAD <i className="fas fa-caret-down"></i>
</div>
<div className="nav-dropdown-content">
<p>
<Link href="/forum">FOROS</Link>
</p>
<p>
<a href="https://wotlk.novawow.com" target="_blank" rel="noopener noreferrer">
WOTLK DB
</a>
</p>
<p>
<a href="/content-creators">VIDEOS</a>
</p>
{loggedIn && (
<p>
<a href="/help">AYUDA</a>
</p>
)}
</div>
</div>
{loggedIn ? (
<div className="nav-dropdown">
<div className="nav-dropdown-btn">
{accountLabel} <i className="fas fa-caret-down"></i>
</div>
<div className="nav-dropdown-content">
<p>
<Link href="/account">MI CUENTA</Link>
</p>
<p>
<a href="javascript:void(0);" onClick={logout}>
DESCONECTAR
</a>
</p>
</div>
</div>
) : (
<Link href="/login" className="last">
CONECTAR
</Link>
)}
</div>
</div>
{/* eslint-disable @next/next/no-img-element */}
<a href="/">
<img
className="nw-long-logo"
src="/nw-themes/nw-ryu/nw-images/nw-logos/nw-long-logo.webp"
alt={SERVER_NAME}
title={SERVER_NAME}
/>
</a>
<a href="javascript:void(0);">
<img
className="nw-logo"
src="/nw-themes/nw-ryu/nw-images/nw-logos/nw-logo.webp"
alt="NW"
title="NW"
/>
</a>
{/* eslint-enable @next/next/no-img-element */}
</header>
)
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Barra de redes sociales — réplica del partial Django `partials/social.html`
* con las clases del tema real (social-media, social-button + marca).
*/
export function Social() {
return (
<div className="social-media">
<span className="big-font">SÍGUENOS EN</span>
<a
href="https://www.facebook.com/NovaWoW/"
className="social-button social-button-responsive facebook"
target="_blank"
title="Facebook NovaWoW"
rel="noopener noreferrer"
>
<div className="social-inline">
<i className="fab fa-facebook-f"></i>
</div>
</a>
<a
href="https://www.instagram.com/NovaWoW/"
className="social-button social-button-responsive instagram"
target="_blank"
title="Instagram NovaWoW"
rel="noopener noreferrer"
>
<div className="social-inline">
<i className="fab fa-instagram"></i>
</div>
</a>
<a
href="https://twitter.com/NovaWoW"
className="social-button social-button-responsive twitter"
target="_blank"
title="Twitter NovaWoW"
rel="noopener noreferrer"
>
<div className="social-inline">
<i className="fab fa-x-twitter"></i>
</div>
</a>
<a
href="https://www.youtube.com/channel/UCaHL8BZcho8AkeM9wckQbyg"
className="social-button social-button-responsive youtube"
target="_blank"
title="Youtube NovaWoW"
rel="noopener noreferrer"
>
<div className="social-inline">
<i className="fab fa-youtube"></i>
</div>
</a>
<a
href="https://discord.novawow.com"
className="social-button social-button-responsive discord"
target="_blank"
title="Discord NovaWoW"
rel="noopener noreferrer"
>
<div className="social-inline">
<i className="fab fa-discord"></i>
</div>
</a>
</div>
)
}
+12 -32
View File
@@ -47,14 +47,9 @@ export function TopicModBar({
if (deleted) {
return (
<div className="mb-6 flex flex-wrap items-center gap-2 rounded-lg border border-red-900/60 bg-red-950/20 px-3 py-2 text-sm">
<span className="mr-1 font-semibold text-red-400">{t('deletedMark')}</span>
<button
type="button"
onClick={() => act('restore')}
disabled={busy}
className="text-xs text-green-400 hover:text-green-300 disabled:opacity-60"
>
<div className="info-box-light separate">
<span className="red-info2">{t('deletedMark')}</span>{' '}
<button type="button" onClick={() => act('restore')} disabled={busy} className="nw-tool-btn green-info">
{t('restoreTopic')}
</button>
</div>
@@ -62,24 +57,14 @@ export function TopicModBar({
}
return (
<div className="mb-6 flex flex-wrap items-center gap-2 rounded-lg border border-nw-border/60 bg-nw-panel-2/40 px-3 py-2 text-sm">
<span className="mr-1 font-semibold text-nw-gold-light">{t('moderation')}:</span>
<button
type="button"
onClick={() => act(locked ? 'unlock' : 'lock')}
disabled={busy}
className="nw-btn-ghost text-xs disabled:opacity-60"
>
<div className="info-box-light separate">
<span className="yellow-info">{t('moderation')}:</span>{' '}
<button type="button" onClick={() => act(locked ? 'unlock' : 'lock')} disabled={busy} className="nw-tool-btn">
{locked ? t('unlock') : t('lock')}
</button>
<button
type="button"
onClick={() => act(sticky ? 'unsticky' : 'sticky')}
disabled={busy}
className="nw-btn-ghost text-xs disabled:opacity-60"
>
</button>{' '}
<button type="button" onClick={() => act(sticky ? 'unsticky' : 'sticky')} disabled={busy} className="nw-tool-btn">
{sticky ? t('unpin') : t('pin')}
</button>
</button>{' '}
{moveForums.length > 0 && (
<select
defaultValue=""
@@ -89,7 +74,7 @@ export function TopicModBar({
e.target.value = ''
if (forumId) act('move', { forumId })
}}
className="nw-input inline-block w-auto py-1 text-xs disabled:opacity-60"
style={{ width: 'auto' }}
aria-label={t('moveTopic')}
>
<option value="" disabled>
@@ -101,13 +86,8 @@ export function TopicModBar({
</option>
))}
</select>
)}
<button
type="button"
onClick={() => act('delete', undefined, t('confirmDeleteTopic'))}
disabled={busy}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-60"
>
)}{' '}
<button type="button" onClick={() => act('delete', undefined, t('confirmDeleteTopic'))} disabled={busy} className="nw-tool-btn red-info2">
{t('deleteTopic')}
</button>
</div>
+12 -8
View File
@@ -35,22 +35,26 @@ export function TransferForm({ characters, price }: { characters: string[]; pric
}
}
const field = 'nw-input'
if (characters.length === 0) return <p className="text-amber-200/70">{t('noCharactersYet')}</p>
if (characters.length === 0) return <p className="second-brown">{t('noCharactersYet')}</p>
return (
<div className="mx-auto max-w-sm text-center">
<form onSubmit={handleSubmit} className="space-y-3">
<select value={character} onChange={(e) => setCharacter(e.target.value)} required className={field}>
<div className="centered">
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>{t('selectCharacter')}</option>
{characters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<input type="text" placeholder={tp('transfer.destination')} value={destination} onChange={(e) => setDestination(e.target.value)} required className={field} />
<button type="submit" disabled={busy || !character || !destination.trim()} className="w-full nw-btn disabled:opacity-60">
<br />
<input type="text" placeholder={tp('transfer.destination')} value={destination} onChange={(e) => setDestination(e.target.value)} required />
<br />
<button type="submit" className="transfer-button" disabled={busy || !character || !destination.trim()}>
{busy ? t('processing') : tp('transfer.pay', { price })}
</button>
</form>
{error && <p className="mt-3 text-red-400">{error}</p>}
<hr />
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
{error && <span className="red-form-response">{error}</span>}
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
const SERVER_NAME = 'NovaWoW'
/**
* Bloque de vídeo de cabecera — réplica del partial Django `partials/video.html`
* (logo principal + vídeo de fondo en bucle). Clases del tema real: div-nw-video,
* nw-main-logo, nw-video.
*/
export function Video() {
return (
<div className="div-nw-video">
<div className="nw-main-logo">
<a href="/">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="nw_main_logo_img"
src="/nw-themes/nw-ryu/nw-images/nw-logos/novawow-main-logo-transparent.webp"
alt={SERVER_NAME}
title={SERVER_NAME}
/>
</a>
</div>
<div id="live-stream-div">
<div id="live-stream-announce">
<a href="./#live-stream" id="live-stream-a">
<i className="fas fa-video"></i>
</a>
</div>
</div>
<video className="nw-video" autoPlay loop muted playsInline>
<source src="/nw-themes/nw-ryu/nw-videos/nw-logo.mp4" />
</video>
</div>
)
}
+28 -29
View File
@@ -37,37 +37,36 @@ export function VotePanel({ sites, canVote }: { sites: VoteSite[]; canVote: bool
}
}
if (sites.length === 0) return <p className="text-amber-200/70">{t('noSites')}</p>
if (sites.length === 0) return <p className="second-brown">{t('noSites')}</p>
return (
<>
<div className="flex flex-wrap justify-center gap-4">
{sites.map((s) => (
<div key={s.id} className="w-40 nw-card p-3 text-center">
{s.image_url && <img src={s.image_url} alt={s.name} className="mx-auto mb-2 max-h-16" />}
<p className="font-semibold text-amber-300">{s.name}</p>
<p className="mb-2 text-xs text-amber-200/60">
{s.points} {t('pv')}
</p>
{canVote ? (
<button
type="button"
onClick={() => vote(s)}
disabled={busyId !== null}
className="w-full rounded bg-amber-600 px-3 py-1 text-sm font-semibold text-[#1b120b] disabled:opacity-60"
>
{busyId === s.id ? t('voting') : t('vote')}
</button>
) : (
<a href={s.url} target="_blank" rel="noopener noreferrer" className="text-sm text-sky-400 hover:underline">
{t('vote')}
</a>
)}
</div>
))}
<div className="centered">
{sites.map((s) => (
<div key={s.id} className="item-box">
{s.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img className="item-img-box" src={s.image_url} alt={s.name} />
)}
<p className="yellow-info">{s.name}</p>
<p className="vp-color small-font">
{s.points} {t('pv')}
</p>
{canVote ? (
<button type="button" className="vote-button" onClick={() => vote(s)} disabled={busyId !== null}>
{busyId === s.id ? t('voting') : t('vote')}
</button>
) : (
<a href={s.url} target="_blank" rel="noopener noreferrer">
{t('vote')}
</a>
)}
</div>
))}
<hr />
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
</div>
{message && <p className={`mt-4 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
{!canVote && <p className="mt-4 text-center text-sm text-amber-200/60">{t('loginToVote')}</p>}
</>
{!canVote && <p className="second-brown small-font">{t('loginToVote')}</p>}
</div>
)
}