Files
NightSpire/web-next/components/ArmoryAchievements.tsx
T
Inna beba7301a1 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>
2026-07-17 01:18:30 +00:00

230 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
import { WowheadRefresh } from './WowheadRefresh'
export type AchCat = {
id: number
name: string
total: number
completed: number
totalPoints: number
earnedPoints: number
pct: number
isFeats: boolean
}
export type AchDetailItem = {
id: number
name: string
description: string
reward: string
icon: string
points: number
date: number
}
const PAGE_SIZE = 20
function ringColor(pct: number) {
if (pct >= 90) return '#4ade5f'
if (pct >= 50) return '#e0902f'
return '#d97b28'
}
export function ArmoryAchievements({
locale,
guid,
categories,
earnedPoints,
totalCompleted,
apiPath = '/api/armory/achievements',
idKey = 'guid',
labels,
}: {
locale: string
guid: number
categories: AchCat[]
earnedPoints: number
totalCompleted: number
apiPath?: string
idKey?: string
labels: {
summary: string
back: string
reward: string
empty: string
of: string
feats: string
}
}) {
const [cat, setCat] = useState<AchCat | null>(null)
const [entries, setEntries] = useState<AchDetailItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const open = async (c: AchCat, p = 1) => {
setCat(c)
setPage(p)
setLoading(true)
try {
const res = await fetch(`${apiPath}?${idKey}=${guid}&cat=${c.id}&page=${p}&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 goto = (p: number) => {
if (!cat || p < 1 || p > pages || p === page || loading) return
open(cat, p)
}
const fmtDate = (unix: number) => {
if (!unix) return ''
const d = new Date(unix * 1000)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
}
// ---- Vista detalle de una categoría ----
if (cat) {
const from = Math.max(1, page - 2)
const to = Math.min(pages, page + 2)
const nums: number[] = []
for (let p = from; p <= to; p++) nums.push(p)
return (
<div className="armory-panel armory-ach2">
<WowheadRefresh dep={`${cat.id}:${page}:${entries.length}`} />
<div className="armory-ach2-head">
<button type="button" className="armory-ach2-back" onClick={() => setCat(null)}>
<i className="fas fa-arrow-left" /> {labels.back}
</button>
<div className="armory-ach2-title">
{cat.name}
<span className="armory-ach2-sub">
{cat.completed} {labels.of} {cat.total}
{cat.isFeats ? '' : ` · ${cat.earnedPoints} / ${cat.totalPoints}`}
</span>
</div>
</div>
{entries.length === 0 && !loading ? (
<p className="armory-hint">{labels.empty}</p>
) : (
<ul className={`armory-ach2-list${loading ? ' loading' : ''}`}>
{entries.map((a) => (
<li className="armory-ach2-item" key={a.id}>
<span className="armory-ach2-icon">
<img src={wowheadIcon(a.icon, 'large')} alt="" loading="lazy" />
</span>
<div className="armory-ach2-body">
<a
className="armory-ach2-name"
href={wowheadUrl('achievement', a.id, locale)}
data-wowhead={wowheadData('achievement', a.id, locale)}
target="_blank"
rel="noreferrer"
>
{a.name}
</a>
{a.description ? <p className="armory-ach2-desc">{a.description}</p> : null}
{a.reward ? (
<p className="armory-ach2-reward">
<i className="fas fa-gift" /> {labels.reward}: {a.reward}
</p>
) : null}
</div>
<div className="armory-ach2-meta">
{a.points > 0 ? (
<span className="armory-ach2-pts">
<i className="fas fa-shield" /> {a.points}
</span>
) : null}
<span className="armory-ach2-date">{fmtDate(a.date)}</span>
</div>
</li>
))}
</ul>
)}
{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>
)
}
// ---- Vista rejilla de categorías ----
const totalAch = categories.reduce((s, c) => s + c.total, 0)
const summary = labels.summary
.replace('{done}', String(totalCompleted))
.replace('{total}', String(totalAch))
.replace('{points}', String(earnedPoints))
return (
<div className="armory-panel armory-ach2">
<p className="armory-ach2-summary">{summary}</p>
<div className="armory-ach-grid">
{categories.map((c) => {
const col = ringColor(c.pct)
return (
<button type="button" className="armory-ach-card" key={c.id} onClick={() => open(c)}>
{c.isFeats ? (
<div className="armory-ach-ring feats">
<i className="fas fa-award" />
</div>
) : (
<div
className="armory-ach-ring"
style={{ background: `conic-gradient(${col} ${c.pct * 3.6}deg, #241f19 0deg)` }}
>
<span className="armory-ach-ring-pct">{c.pct}%</span>
</div>
)}
<span className="armory-ach-cat-name">{c.name}</span>
<span className="armory-ach-cat-pts">
<i className="fas fa-shield" /> {c.isFeats ? c.completed : c.earnedPoints}
</span>
</button>
)
})}
</div>
</div>
)
}