beba7301a1
- 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>
249 lines
9.7 KiB
TypeScript
249 lines
9.7 KiB
TypeScript
import { notFound } from 'next/navigation'
|
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
|
import { Link } from '@/i18n/navigation'
|
|
import {
|
|
getGuildInfo,
|
|
getGuildRoster,
|
|
getGuildRecentAchievements,
|
|
getRealmName,
|
|
getGuildAchievementOverview,
|
|
getGuildRoleBreakdown,
|
|
} from '@/lib/armory'
|
|
import { getSession } from '@/lib/session'
|
|
import { isGuildMemberByAccount } from '@/lib/armory'
|
|
import { className, classColor } from '@/lib/wow-data'
|
|
import { wowheadIcon } from '@/lib/wowhead'
|
|
import { PageShell } from '@/components/PageShell'
|
|
import { ArmoryGuildRoster } from '@/components/ArmoryGuildRoster'
|
|
import { ArmoryAchievements } from '@/components/ArmoryAchievements'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
const PAGE_SIZE = 25
|
|
|
|
export default async function GuildPage({
|
|
params,
|
|
searchParams,
|
|
}: {
|
|
params: Promise<{ locale: string; guid: string }>
|
|
searchParams: Promise<{ tab?: string }>
|
|
}) {
|
|
const { locale, guid } = await params
|
|
const { tab } = await searchParams
|
|
setRequestLocale(locale)
|
|
const t = await getTranslations('Armory')
|
|
|
|
const guildId = Number(guid)
|
|
const info = await getGuildInfo(guildId)
|
|
if (!info) notFound()
|
|
|
|
const isLogros = tab === 'logros'
|
|
const emblemHue = (info.emblem.bg * 7) % 360
|
|
|
|
return (
|
|
<PageShell title={info.name}>
|
|
<div className="main-wide armory-guild-page">
|
|
<p className="forum-path">
|
|
<Link href="/armory">← {t('backToArmory')}</Link>
|
|
</p>
|
|
|
|
{/* Cabecera de hermandad */}
|
|
<div className="armory-guild-head">
|
|
<div className="armory-guild-emblem" style={{ background: `hsl(${emblemHue} 45% 22%)` }}>
|
|
<i className="fas fa-dragon" style={{ color: `hsl(${(emblemHue + 40) % 360} 70% 65%)` }} />
|
|
</div>
|
|
<div className="armory-guild-headinfo">
|
|
<h1 className="armory-guild-title">{info.name}</h1>
|
|
<div className="armory-guild-realm">{/* reino */}</div>
|
|
<div className="armory-guild-stats">
|
|
<span className="armory-guild-stat">
|
|
<i className="fas fa-shield" /> {info.guildPoints.toLocaleString(locale)}
|
|
</span>
|
|
<span className="armory-guild-stat">
|
|
<i className="fas fa-users" /> {info.memberCount} {t('members')}
|
|
</span>
|
|
{info.createdYear > 0 && (
|
|
<span className="armory-guild-stat muted">
|
|
{t('guildFounded')} {info.createdYear}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="armory-guild-tabbar">
|
|
<Link href={`/armory/guild/${guildId}`} className={`armory-guild-tab${!isLogros ? ' active' : ''}`}>
|
|
{t('guild')}
|
|
</Link>
|
|
<Link href={`/armory/guild/${guildId}?tab=logros`} className={`armory-guild-tab${isLogros ? ' active' : ''}`}>
|
|
{t('guildAchievements')}
|
|
</Link>
|
|
</div>
|
|
|
|
{isLogros ? (
|
|
<GuildLogros guildId={guildId} locale={locale} t={t} />
|
|
) : (
|
|
<GuildRoster guildId={guildId} locale={locale} info={info} t={t} />
|
|
)}
|
|
</div>
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
/* ---- Vista Hermandad (roster + barra lateral) ---- */
|
|
async function GuildRoster({
|
|
guildId,
|
|
locale,
|
|
info,
|
|
t,
|
|
}: {
|
|
guildId: number
|
|
locale: string
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
info: any
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
t: any
|
|
}) {
|
|
const session = await getSession()
|
|
const accountId = session.accountId ?? 0
|
|
const [roster, recent, realm, roleBreak, isMember] = await Promise.all([
|
|
getGuildRoster(guildId, { page: 1, pageSize: PAGE_SIZE, classes: [], roles: [], sort: 'rank' }),
|
|
getGuildRecentAchievements(guildId, locale, 5),
|
|
getRealmName(),
|
|
getGuildRoleBreakdown(guildId),
|
|
isGuildMemberByAccount(accountId, guildId),
|
|
])
|
|
const motdState: 'guest' | 'member' | 'nonmember' = !accountId ? 'guest' : isMember ? 'member' : 'nonmember'
|
|
const roleRows: { key: 'tank' | 'healer' | 'dps'; label: string; color: string; count: number }[] = [
|
|
{ key: 'tank', label: t('roleTank'), color: '#4a90d9', count: roleBreak.tank },
|
|
{ key: 'healer', label: t('roleHealer'), color: '#4ade5f', count: roleBreak.healer },
|
|
{ key: 'dps', label: t('roleDps'), color: '#d9534f', count: roleBreak.dps },
|
|
]
|
|
const maxRole = Math.max(1, roleBreak.tank, roleBreak.healer, roleBreak.dps)
|
|
const maxCount = Math.max(1, ...info.classBreakdown.map((c: { count: number }) => c.count))
|
|
const fmtDate = (unix: number) => {
|
|
if (!unix) return ''
|
|
const d = new Date(unix * 1000)
|
|
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
}
|
|
|
|
return (
|
|
<div className="armory-guild-layout">
|
|
<div className="armory-guild-main">
|
|
<h2 className="armory-guild-section">
|
|
{t('guildMembers')} <span className="armory-guild-realm-inline">· {realm}</span>
|
|
</h2>
|
|
<ArmoryGuildRoster
|
|
guildid={guildId}
|
|
locale={locale}
|
|
initial={roster.members}
|
|
total={roster.total}
|
|
pageSize={PAGE_SIZE}
|
|
classes={info.classBreakdown.map((c: { class: number }) => c.class)}
|
|
labels={{
|
|
view: t('guildView'), sortRank: t('guildSortRank'), sortPoints: t('guildSortPoints'),
|
|
sortLevel: t('guildSortLevel'), sortName: t('guildSortName'), filterClass: t('guildFilterClass'),
|
|
allClasses: t('guildAllClasses'), filterRole: t('guildFilterRole'), allRoles: t('guildAllRoles'),
|
|
roleTank: t('roleTank'), roleHealer: t('roleHealer'), roleDps: t('roleDps'),
|
|
sortIlvl: t('guildSortIlvl'), ilvl: t('colIlvl'),
|
|
name: t('colName'), race: t('colRace'), class: t('colClass'), role: t('colFunction'),
|
|
level: t('colLevel'), rank: t('colRank'), points: t('guildSortPoints'),
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<aside className="armory-guild-side">
|
|
<div className="armory-guild-card">
|
|
<h3 className="armory-guild-card-title">{t('guildMotd')}</h3>
|
|
{motdState === 'member' ? (
|
|
<div className="armory-guild-motd">{info.motd?.trim() ? info.motd : t('guildMotdEmpty')}</div>
|
|
) : motdState === 'guest' ? (
|
|
<div className="armory-guild-motd-locked">
|
|
<i className="fas fa-lock" />{' '}
|
|
{t('guildMotdGuest')} <Link href="/my-account" className="armory-guild-login">{t('login')}</Link>
|
|
</div>
|
|
) : (
|
|
<div className="armory-guild-motd-locked">
|
|
<i className="fas fa-lock" /> {t('guildMotdLocked')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{recent.length > 0 && (
|
|
<div className="armory-guild-card">
|
|
<h3 className="armory-guild-card-title">{t('guildRecent')}</h3>
|
|
<ul className="armory-guild-recent">
|
|
{recent.map((a) => (
|
|
<li key={a.id} className="armory-guild-recent-item">
|
|
<img src={wowheadIcon(a.icon, 'medium')} alt="" className="armory-guild-recent-ico" loading="lazy" />
|
|
<span className="armory-guild-recent-name">{a.name}</span>
|
|
<span className="armory-guild-recent-date">{fmtDate(a.date)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{info.classBreakdown.length > 0 && (
|
|
<div className="armory-guild-card">
|
|
<h3 className="armory-guild-card-title">{t('guildOverview')}</h3>
|
|
<div className="armory-guild-chart-lbl">{t('guildClassBreak')}</div>
|
|
<div className="armory-guild-chart">
|
|
{info.classBreakdown.map((c: { class: number; count: number }) => (
|
|
<div className="armory-guild-bar-col" key={c.class} title={`${className(c.class, locale)}: ${c.count}`}>
|
|
<div className="armory-guild-bar-wrap">
|
|
<div
|
|
className="armory-guild-bar"
|
|
style={{ height: `${Math.round((c.count / maxCount) * 100)}%`, background: classColor(c.class) }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="armory-guild-chart-lbl" style={{ marginTop: 14 }}>{t('guildRoleBreak')}</div>
|
|
<div className="armory-guild-roles">
|
|
{roleRows.map((r) => (
|
|
<div className="armory-guild-role-row" key={r.key}>
|
|
<span className="armory-guild-role-lbl" style={{ color: r.color }}>{r.label}</span>
|
|
<div className="armory-guild-role-track">
|
|
<div className="armory-guild-role-fill" style={{ width: `${Math.round((r.count / maxRole) * 100)}%`, background: r.color }} />
|
|
</div>
|
|
<span className="armory-guild-role-n">{r.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ---- Vista Logros de hermandad (anillos por categoría) ---- */
|
|
async function GuildLogros({
|
|
guildId,
|
|
locale,
|
|
t,
|
|
}: {
|
|
guildId: number
|
|
locale: string
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
t: any
|
|
}) {
|
|
const overview = await getGuildAchievementOverview(guildId, null, locale)
|
|
return (
|
|
<ArmoryAchievements
|
|
locale={locale}
|
|
guid={guildId}
|
|
categories={overview.categories}
|
|
earnedPoints={overview.earnedPoints}
|
|
totalCompleted={overview.totalCompleted}
|
|
apiPath="/api/armory/guild-achievements"
|
|
idKey="guild"
|
|
labels={{
|
|
summary: t('achSummaryLine'), back: t('achBack'), reward: t('achReward'),
|
|
empty: t('achEmptyCat'), of: t('achOf'), feats: t('achFeats'),
|
|
}}
|
|
/>
|
|
)
|
|
}
|