Armería: PvP campos de batalla, logros por categoría, bandas/mazmorras y página de hermandad

- PvP: pestaña Campos de batalla (pvpstats) con resultado, stats por partida y fecha/hora.
- Logros: rejilla por categoría con anillos de progreso + detalle por categoría
  (datos de Achievement.db2 + Achievement_Category.db2, iconos vía wowhead).
- Bandas: progreso por dificultad (10/25/heroico) con jefes reales de DungeonEncounter.db2
  y tooltip de jefes; agrupado por expansión (Clásico/TBC/WotLK).
- Mazmorras: mismo sistema, Normal + Heroico, Clásico/TBC/WotLK.
- Página de hermandad estilo armería: cabecera (emblema, puntos = unión de logros de
  miembros sin duplicar, nº miembros, fundada), pestañas Hermandad/Logros, roster con
  iconos de clase/raza, columna Función (rol por spec), filtros clase/función,
  orden por rango/puntos/nivel de objeto/nivel/nombre, paginación.
- Barra lateral: mensaje diario con login (iron-session, gated por pertenencia),
  logros recientes y desgloses de clases y funciones.
- Cabecera de personaje: enlace a la hermandad + nombre del reino.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 01:18:30 +00:00
parent 476e5dba87
commit beba7301a1
203 changed files with 31304 additions and 269 deletions
+160
View File
@@ -0,0 +1,160 @@
'use client'
import { useEffect, useRef, useState } from 'react'
export type PvpRankEntry = { rank: number; guid: number; name: string; race: number; class: number; kills: number; playedTime: number }
const MEDAL = ['🥇', '🥈', '🥉']
/** Ranking mundial de bajas: búsqueda por nombre + paginación (API). */
export function ArmoryPvpRank({
locale,
initial,
total: initialTotal,
pageSize,
labels,
}: {
locale: string
initial: PvpRankEntry[]
total: number
pageSize: number
labels: {
title: string
desc: string
search: string
empty: string
character: string
kills: string
playedTime: string
}
}) {
const [entries, setEntries] = useState<PvpRankEntry[]>(initial)
const [total, setTotal] = useState(initialTotal)
const [page, setPage] = useState(1)
const [q, setQ] = useState('')
const [loading, setLoading] = useState(false)
const pages = Math.max(1, Math.ceil(total / pageSize))
const load = async (p: number, search: string) => {
setLoading(true)
try {
const res = await fetch(`/api/armory/pvprank?page=${p}&q=${encodeURIComponent(search)}&locale=${locale}`)
const data = await res.json()
setEntries(Array.isArray(data.entries) ? data.entries : [])
setTotal(Number(data.total) || 0)
} catch {
setEntries([])
} finally {
setLoading(false)
}
}
const skip = useRef(true)
useEffect(() => {
if (skip.current) {
skip.current = false
return
}
const t = setTimeout(() => {
setPage(1)
load(1, q)
}, 300)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q])
const goto = (p: number) => {
if (p < 1 || p > pages || p === page || loading) return
setPage(p)
load(p, q)
}
const fmtTime = (sec: number) => {
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
return `${d}d ${h}h ${m}m`
}
const win = 2
const from = Math.max(1, page - win)
const to = Math.min(pages, page + win)
const nums: number[] = []
for (let p = from; p <= to; p++) nums.push(p)
return (
<div className="armory-panel armory-pvprank-panel">
<div className="armory-pvprank-header">
<div className="armory-quest-title">
<i className="fas fa-trophy" /> {labels.title}
</div>
<p className="armory-pvprank-desc">{labels.desc}</p>
</div>
<input className="armory-ach-search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={labels.search} />
{entries.length === 0 ? (
<p className="armory-hint">{labels.empty}</p>
) : (
<table className={`armory-quest-table armory-rank-table${loading ? ' loading' : ''}`}>
<thead>
<tr>
<th className="armory-rank-th-pos">#</th>
<th>{labels.character}</th>
<th className="armory-rank-th-kills">{labels.kills}</th>
<th className="armory-rank-th-time">{labels.playedTime}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.guid}>
<td className="armory-rank-pos">
{e.rank <= 3 ? <span className="armory-rank-medal">{MEDAL[e.rank - 1]}</span> : null} {e.rank}
</td>
<td>
<a className="armory-rank-name" href={`/${locale}/armory/character/${e.guid}`}>
{e.name}
</a>
</td>
<td className="armory-rank-kills">{e.kills.toLocaleString(locale)}</td>
<td className="armory-rank-time">{fmtTime(e.playedTime)}</td>
</tr>
))}
</tbody>
</table>
)}
{pages > 1 && (
<div className="armory-pager">
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
</button>
{from > 1 && (
<>
<button onClick={() => goto(1)} type="button">
1
</button>
{from > 2 && <span className="armory-pager-dots"></span>}
</>
)}
{nums.map((p) => (
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
{p}
</button>
))}
{to < pages && (
<>
{to < pages - 1 && <span className="armory-pager-dots"></span>}
<button onClick={() => goto(pages)} type="button">
{pages}
</button>
</>
)}
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
</button>
</div>
)}
</div>
)
}