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
+278
View File
@@ -0,0 +1,278 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import {
getCharacter,
getCharacterEquipment,
getCharacterCustomizations,
getCharacterStats,
getCharacterProfessions,
getItemIcons,
getAchievementPoints,
getCharacterTalents,
getCharacterSpecs,
getSpellIcons,
getActivityPage,
} from '@/lib/armory'
import { raceName, className, classColor, factionOf, classPower } from '@/lib/wow-data'
import { PageShell } from '@/components/PageShell'
import { ArmoryPaperdoll, type SheetItem } from '@/components/ArmoryPaperdoll'
import { wowheadIcon } from '@/lib/wowhead'
import { ArmoryTalents } from '@/components/ArmoryTalents'
import { ArmoryActivity } from '@/components/ArmoryActivity'
import { WowheadRefresh } from '@/components/WowheadRefresh'
export const dynamic = 'force-dynamic'
export default async function CharacterPage({
params,
}: {
params: Promise<{ locale: string; guid: string }>
}) {
const { locale, guid } = await params
setRequestLocale(locale)
const t = await getTranslations('Armory')
const id = Number(guid)
const character = await getCharacter(id)
if (!character) notFound()
const [equipment, customizations, stats, professions, achievementPoints, talents, specs, activityData] = await Promise.all([
getCharacterEquipment(id, locale),
getCharacterCustomizations(id),
getCharacterStats(id),
getCharacterProfessions(id, locale),
getAchievementPoints(id),
getCharacterTalents(id, character.class, locale),
getCharacterSpecs(id, character.class, locale),
getActivityPage(id, locale, 1, 10, ''),
])
const icons = await getItemIcons(equipment.map((e) => e.entry))
const spellIcons = talents ? await getSpellIcons(talents.trees.flatMap((tr) => tr.talents.map((c) => c.spell))) : {}
const items: SheetItem[] = equipment.map((e) => ({
slot: e.slot,
entry: e.entry,
name: e.name,
quality: e.quality,
itemLevel: e.itemLevel,
inventoryType: e.inventoryType,
displayId: e.displayId,
transmogDisplayId: e.transmogDisplayId,
ench: e.ench,
gems: e.gems,
icon: icons[e.entry] ?? null,
}))
const gear = equipment.filter((e) => e.slot !== 3 && e.slot !== 18 && e.itemLevel > 0)
const avgItemLevel = gear.length ? Math.round(gear.reduce((s, e) => s + e.itemLevel, 0) / gear.length) : 0
const faction = factionOf(character.race)
const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
const power = classPower(character.class, locale)
const statRows = stats
? [
{ label: t('strength'), value: `${stats.strength}` },
{ label: t('agility'), value: `${stats.agility}` },
{ label: t('stamina'), value: `${stats.stamina}` },
{ label: t('intellect'), value: `${stats.intellect}` },
{ label: t('armor'), value: `${stats.armor}` },
{ label: t('attackPower'), value: `${stats.attackPower}` },
{ label: t('rangedAttackPower'), value: `${stats.rangedAttackPower}` },
{ label: t('spellPower'), value: `${stats.spellPower}` },
{ label: t('critChance'), value: `${stats.critPct.toFixed(2)}%` },
{ label: t('spellCrit'), value: `${stats.spellCritPct.toFixed(2)}%` },
{ label: t('dodge'), value: `${stats.dodgePct.toFixed(2)}%` },
{ label: t('parry'), value: `${stats.parryPct.toFixed(2)}%` },
{ label: t('block'), value: `${stats.blockPct.toFixed(2)}%` },
{ label: t('resilience'), value: `${stats.resilience}` },
]
: []
return (
<PageShell title={character.name}>
<div className="main-wide">
<p className="forum-path">
<Link href="/armory">← {t('backToArmory')}</Link>
</p>
<div className="armory-sheet">
<div className="armory-sheet-head">
<h1 className="armory-char-name" style={{ color: classColor(character.class) }}>
{character.name}
{avgItemLevel > 0 ? (
<span className="armory-char-ilvl">
{' '}
({t('itemLevel')} {avgItemLevel})
</span>
) : null}
</h1>
<div className="armory-char-sub">
{t('level')} {character.level} · {raceName(character.race, locale)} ·{' '}
<span style={{ color: classColor(character.class) }}>{className(character.class, locale)}</span>
</div>
<div className="armory-char-tags">
{factionLabel && <span className={`armory-faction ${faction}`}>{factionLabel}</span>}
<span className={`armory-status ${character.online ? 'online' : 'offline'}`}>
{character.online ? t('online') : t('offline')}
</span>
{character.guildName && character.guildId && (
<Link href={`/armory/guild/${character.guildId}`} className="armory-guild-tag">
&lt;{character.guildName}&gt;
</Link>
)}
<span className="armory-achpoints" title={t('achievementPoints')}>
<i className="fas fa-star" /> {achievementPoints.toLocaleString(locale)}
</span>
</div>
</div>
<ArmoryPaperdoll
race={character.race}
gender={character.gender}
customizations={customizations}
items={items}
health={character.health}
power={character.power}
powerLabel={power.label}
powerColor={power.color}
locale={locale}
labels={{
loading: t('modelLoading'),
unavailable: t('modelUnavailable'),
health: t('health'),
itemLevel: t('itemLevel'),
model: t('model3d'),
list: t('equipment'),
}}
/>
{specs.primary && (
<div className="armory-panel armory-spec-panel">
<h2 className="armory-section-title">{t('specialization')}</h2>
<div className="armory-specs">
<div className="armory-spec-box active">
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.primary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.primary.name}</div>
<div className="armory-spec-box-dist">{specs.primary.dist.join(' / ')}</div>
</div>
</div>
<div className={`armory-spec-box ${specs.secondary ? '' : 'na'}`}>
{specs.secondary ? (
<>
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.secondary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.secondary.name}</div>
<div className="armory-spec-box-dist">{specs.secondary.dist.join(' / ')}</div>
</div>
</>
) : (
<span className="armory-spec-box-na">N/A</span>
)}
</div>
</div>
</div>
)}
{talents && talents.total > 0 && (
<div className="armory-panel armory-talents-panel">
<h2 className="armory-section-title">{t('talents')}</h2>
<WowheadRefresh dep={`talents-${id}`} />
<ArmoryTalents
trees={talents.trees}
spec={talents.spec}
total={talents.total}
maxPoints={Math.max(0, character.level - 9)}
locale={locale}
spellIcons={spellIcons}
/>
</div>
)}
{activityData.entries.length > 0 && (
<div className="armory-panel armory-activity-panel">
<h2 className="armory-section-title">{t('recentActivity')}</h2>
<ArmoryActivity
guid={id}
locale={locale}
initial={activityData.entries}
total={activityData.total}
pageSize={10}
labels={{ earned: t('earned'), search: t('achSearch'), empty: t('noAchievements') }}
/>
</div>
)}
<div className="armory-panels">
<div className="armory-panel">
<h2 className="armory-section-title">{t('stats')}</h2>
{stats ? (
<div className="armory-stats-grid">
{statRows.map((r) => (
<div className="armory-stat" key={r.label}>
<span className="armory-stat-label">{r.label}</span>
<span className="armory-stat-value">{r.value}</span>
</div>
))}
</div>
) : (
<p className="armory-hint armory-stats-pending">{t('statsPending')}</p>
)}
</div>
<div className="armory-panel">
<h2 className="armory-section-title">{t('professions')}</h2>
{professions.length ? (
<div className="armory-prof-groups">
{(['primary', 'secondary'] as const).map((kind) => {
const list = professions.filter((p) => p.kind === kind)
if (!list.length) return null
return (
<div className="armory-prof-group" key={kind}>
<h3 className="armory-prof-grouptitle">
{kind === 'primary' ? t('primaryProfs') : t('secondaryProfs')}
</h3>
{list.map((p) => (
<div className="armory-prof-row" key={p.skill}>
<span
className="armory-prof-icon"
style={{ backgroundImage: `url(${wowheadIcon(p.icon, 'small')})` }}
/>
<div className="armory-prof-body">
<div className="armory-prof-head">
<span className="armory-prof-name">
{p.name}
{p.rank ? <span className="armory-prof-rank"> · {p.rank}</span> : null}
</span>
<span className="armory-prof-value">
{p.value}
<span className="armory-prof-max"> / {p.max}</span>
</span>
</div>
<div className="armory-prof-bar">
<span style={{ width: `${Math.round((p.value / Math.max(1, p.max)) * 100)}%` }} />
</div>
</div>
</div>
))}
</div>
)
})}
</div>
) : (
<p className="armory-hint">{t('noProfessions')}</p>
)}
</div>
</div>
</div>
</div>
</PageShell>
)
}