diff --git a/web-next/app/[locale]/armory/character/[guid]/page.tsx b/web-next/app/[locale]/armory/character/[guid]/page.tsx
new file mode 100644
index 0000000..936faab
--- /dev/null
+++ b/web-next/app/[locale]/armory/character/[guid]/page.tsx
@@ -0,0 +1,119 @@
+import { notFound } from 'next/navigation'
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { Link } from '@/i18n/navigation'
+import { getCharacter, getCharacterEquipment } from '@/lib/armory'
+import { raceName, className, classColor, factionOf, EQUIP_SLOTS } from '@/lib/wow-data'
+import { wowheadUrl, wowheadData } from '@/lib/wowhead'
+import { PageShell } from '@/components/PageShell'
+import { WowheadRefresh } from '@/components/WowheadRefresh'
+import { ArmoryModel3D } from '@/components/ArmoryModel3D'
+
+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 = await getCharacterEquipment(id, locale)
+ const bySlot = new Map(equipment.map((e) => [e.slot, e]))
+
+ const faction = factionOf(character.race)
+ const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
+
+ // Modelo 3D: raza, género y equipo (entry + display; transmog si lo hay).
+ const modelEquip = equipment
+ .map((e) => ({
+ slot: e.slot,
+ entry: e.transmogEntry ?? e.entry,
+ displayid: e.transmogDisplayId ?? e.displayId ?? 0,
+ }))
+ .filter((e) => e.displayid > 0)
+
+ return (
+
+
+
+
+ ← {t('backToArmory')}
+
+
+
+ {/* Columna izquierda: identidad + modelo 3D */}
+
+
+ {character.name}
+
+
+ {t('level')} {character.level} · {raceName(character.race, locale)} ·{' '}
+ {className(character.class, locale)}
+
+
+ {factionLabel && {factionLabel}}
+
+ {character.online ? t('online') : t('offline')}
+
+ {character.guildName && character.guildId && (
+
+ <{character.guildName}>
+
+ )}
+
+
+
+
+ {/* Columna derecha: equipo */}
+
+
{t('equipment')}
+ {equipment.length === 0 ? (
+
{t('noEquipment')}
+ ) : (
+
+ {EQUIP_SLOTS.map(({ slot, es, en }) => {
+ const item = bySlot.get(slot)
+ const slotName = locale === 'en' ? en : es
+ if (!item) {
+ return (
+
+ {slotName}
+
+ )
+ }
+ return (
+
+
+ {item.name}
+
+
+ {slotName}
+ {item.itemLevel ? ` · ${item.itemLevel}` : ''}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/web-next/app/[locale]/armory/guild/[guid]/page.tsx b/web-next/app/[locale]/armory/guild/[guid]/page.tsx
new file mode 100644
index 0000000..8e50c3f
--- /dev/null
+++ b/web-next/app/[locale]/armory/guild/[guid]/page.tsx
@@ -0,0 +1,54 @@
+import { notFound } from 'next/navigation'
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { Link } from '@/i18n/navigation'
+import { getGuild } from '@/lib/armory'
+import { raceName, className, classColor } from '@/lib/wow-data'
+import { PageShell } from '@/components/PageShell'
+
+export const dynamic = 'force-dynamic'
+
+export default async function GuildPage({
+ params,
+}: {
+ params: Promise<{ locale: string; guid: string }>
+}) {
+ const { locale, guid } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Armory')
+
+ const guild = await getGuild(Number(guid))
+ if (!guild) notFound()
+
+ return (
+
+
+
+ ← {t('backToArmory')}
+
+
+
+ {guild.members.length} {t('members')}
+ {guild.leaderName && (
+ <>
+ {' · '}
+ {t('leader')}: {guild.leaderName}
+ >
+ )}
+
+
+
+ {guild.members.map((m) => (
+
+
+ {m.name}
+
+
+ {t('level')} {m.level} · {raceName(m.race, locale)} · {className(m.class, locale)}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/web-next/app/[locale]/armory/layout.tsx b/web-next/app/[locale]/armory/layout.tsx
new file mode 100644
index 0000000..9f03957
--- /dev/null
+++ b/web-next/app/[locale]/armory/layout.tsx
@@ -0,0 +1,8 @@
+import '@/app/forum.css'
+import '@/app/armory.css'
+
+// La armería reutiliza clases del foro (main-wide, nice_button, forum-input, colores
+// de calidad qN) y añade las suyas en armory.css. Todas las rutas /armory heredan esto.
+export default function ArmoryLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/web-next/app/[locale]/armory/page.tsx b/web-next/app/[locale]/armory/page.tsx
new file mode 100644
index 0000000..f0e94cf
--- /dev/null
+++ b/web-next/app/[locale]/armory/page.tsx
@@ -0,0 +1,117 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { Link } from '@/i18n/navigation'
+import { searchCharacters, searchItems, searchGuilds } from '@/lib/armory'
+import { raceName, className, classColor } from '@/lib/wow-data'
+import { wowheadUrl, wowheadData } from '@/lib/wowhead'
+import { PageShell } from '@/components/PageShell'
+import { ArmorySearchBox } from '@/components/ArmorySearchBox'
+import { WowheadRefresh } from '@/components/WowheadRefresh'
+
+export const dynamic = 'force-dynamic'
+
+const TABS = ['characters', 'items', 'guilds'] as const
+type Tab = (typeof TABS)[number]
+
+export default async function ArmoryPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams: Promise<{ tab?: string; q?: string }>
+}) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Armory')
+ const sp = await searchParams
+ const tab: Tab = TABS.includes(sp.tab as Tab) ? (sp.tab as Tab) : 'characters'
+ const q = (sp.q ?? '').trim()
+ const hasQuery = q.length >= 3
+
+ const characters = hasQuery && tab === 'characters' ? await searchCharacters(q) : []
+ const items = hasQuery && tab === 'items' ? await searchItems(q, locale) : []
+ const guilds = hasQuery && tab === 'guilds' ? await searchGuilds(q) : []
+
+ const tabLabel: Record = {
+ characters: t('tabCharacters'),
+ items: t('tabItems'),
+ guilds: t('tabGuilds'),
+ }
+
+ return (
+
+
+
+
+
+
+ {TABS.map((tb) => (
+
+ {tabLabel[tb]}
+
+ ))}
+
+
+ {!hasQuery ? (
+
{t('searchHint')}
+ ) : tab === 'characters' ? (
+ characters.length === 0 ? (
+
{t('noResults')}
+ ) : (
+
+ {characters.map((c) => (
+
+
+ {c.name}
+
+
+ {t('level')} {c.level} · {raceName(c.race, locale)} · {className(c.class, locale)}
+
+
+ ))}
+
+ )
+ ) : tab === 'items' ? (
+ items.length === 0 ? (
+
{t('noResults')}
+ ) : (
+
+ {items.map((it) => (
+
+ ))}
+
+ )
+ ) : guilds.length === 0 ? (
+
{t('noResults')}
+ ) : (
+
+ {guilds.map((g) => (
+
+ {g.name}
+
+ {g.members} {t('members')}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/web-next/app/armory.css b/web-next/app/armory.css
new file mode 100644
index 0000000..89331b8
--- /dev/null
+++ b/web-next/app/armory.css
@@ -0,0 +1,67 @@
+/* Estilos de la armería, en la línea del tema (oscuro + dorado #d79602). Reutiliza
+ main-wide, nice_button, forum-input y los colores de calidad qN de forum.css. */
+
+.armory-search { display: flex; gap: 10px; align-items: center; margin: 10px 0 18px; flex-wrap: wrap; }
+.armory-search .forum-input { flex: 1 1 260px; margin: 0; }
+
+.armory-tabs { display: flex; gap: 6px; border-bottom: 1px solid #2a2723; margin-bottom: 16px; flex-wrap: wrap; }
+.armory-tab {
+ padding: 8px 16px; color: #8a8578; text-decoration: none; font-weight: bold; text-transform: uppercase;
+ font-size: 13px; border-bottom: 2px solid transparent; transition: all .15s;
+}
+.armory-tab:hover { color: #d79602; }
+.armory-tab.active { color: #d79602; border-bottom-color: #d79602; }
+
+.armory-hint { color: #6d6a5e; text-align: center; padding: 24px 0; }
+
+.armory-list { display: flex; flex-direction: column; gap: 6px; }
+.armory-row {
+ display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
+ padding: 10px 14px; border-radius: 3px; text-decoration: none;
+ background: rgba(0, 0, 0, .18); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
+ transition: background .15s;
+}
+a.armory-row:hover { background: rgba(255, 255, 255, .05); }
+.armory-name { font-weight: bold; font-size: 14px; color: #d4cdbb; text-decoration: none; }
+a.armory-name:hover { text-decoration: underline; }
+.armory-meta { font-size: 12px; color: #6d6a5e; }
+
+/* Ficha de personaje: dos columnas (modelo 3D | equipo). */
+.armory-char { display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; }
+.armory-char-left { flex: 1 1 320px; }
+.armory-char-right { flex: 1 1 320px; }
+.armory-char-name { font-size: 28px; font-weight: bold; margin: 0; }
+.armory-char-sub { color: #8a8578; font-size: 14px; margin: 6px 0 10px; }
+.armory-char-tags { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
+.armory-faction { font-size: 11px; text-transform: uppercase; font-weight: bold; padding: 2px 8px; border-radius: 3px; }
+.armory-faction.alliance { color: #4a90d9; background: rgba(74, 144, 217, .12); }
+.armory-faction.horde { color: #cc3038; background: rgba(204, 48, 56, .12); }
+.armory-status { font-size: 11px; text-transform: uppercase; font-weight: bold; }
+.armory-status.online { color: #1eff00; }
+.armory-status.offline { color: #6d6a5e; }
+.armory-guild-tag { color: #8a8578; font-size: 13px; text-decoration: none; }
+.armory-guild-tag:hover { color: #d79602; }
+
+.armory-section-title { font-size: 15px; text-transform: uppercase; color: #8a8578; margin: 0 0 12px; }
+.armory-equip { display: flex; flex-direction: column; gap: 4px; }
+.armory-equip-slot {
+ display: flex; justify-content: space-between; align-items: center; gap: 10px;
+ padding: 7px 12px; border-radius: 3px; background: rgba(0, 0, 0, .18);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
+}
+.armory-equip-slot.empty { opacity: .45; }
+.armory-item { font-weight: bold; font-size: 13px; text-decoration: none; }
+.armory-item:hover { text-decoration: none; }
+.armory-slot-name { font-size: 11px; color: #6d6a5e; text-align: right; white-space: nowrap; }
+
+/* Visor 3D */
+.armory-model {
+ margin-top: 8px; border-radius: 3px; overflow: hidden; background: rgba(0, 0, 0, .3);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
+}
+.armory-model-canvas { width: 100%; height: 420px; }
+.armory-model .armory-hint { padding: 12px; }
+
+@media (max-width: 767px) {
+ .armory-model-canvas { height: 320px; }
+}
diff --git a/web-next/components/ArmoryModel3D.tsx b/web-next/components/ArmoryModel3D.tsx
new file mode 100644
index 0000000..1b08e62
--- /dev/null
+++ b/web-next/components/ArmoryModel3D.tsx
@@ -0,0 +1,99 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+
+/**
+ * Visor 3D del personaje. Usa la librería `wow-model-viewer` (generateModels), que a
+ * su vez necesita jQuery y el ZamModelViewer de wowhead (wow.zamimg.com) cargados en
+ * global. Se cargan bajo demanda solo en esta ficha.
+ *
+ * OJO: es integración cliente que depende de un CDN externo (zamimg) y no se puede
+ * verificar sin navegador; por eso todo va envuelto en try/catch y con un temporizador
+ * de reserva: si el modelo no aparece, se muestra un aviso en lugar de romper la ficha.
+ *
+ * El equipo se pasa como {slot, entry, displayid}; la librería mapea cada ítem a su
+ * slot del visor. displayid = transmog si lo hay, si no el del ítem equipado.
+ */
+
+const ZAM_VIEWER = 'https://wow.zamimg.com/modelviewer/live/viewer/viewer.min.js'
+const JQUERY = 'https://code.jquery.com/jquery-3.7.1.min.js'
+const CONTENT_PATH = 'https://wow.zamimg.com/modelviewer/live/'
+
+type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string }
+
+function loadScript(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ if (document.querySelector(`script[src="${src}"]`)) return resolve()
+ const s = document.createElement('script')
+ s.src = src
+ s.async = true
+ s.onload = () => resolve()
+ s.onerror = () => reject(new Error(`no se pudo cargar ${src}`))
+ document.head.appendChild(s)
+ })
+}
+
+export function ArmoryModel3D({
+ race,
+ gender,
+ equip,
+ labels,
+}: {
+ race: number
+ gender: number
+ equip: { slot: number; entry: number; displayid: number }[]
+ labels: { loading: string; unavailable: string }
+}) {
+ const ref = useRef(null)
+ const [state, setState] = useState<'loading' | 'ok' | 'fail'>('loading')
+
+ useEffect(() => {
+ let cancelled = false
+ const failTimer = setTimeout(() => !cancelled && setState((s) => (s === 'loading' ? 'fail' : s)), 12000)
+
+ ;(async () => {
+ try {
+ const w = window as Win
+ w.CONTENT_PATH = CONTENT_PATH
+ await loadScript(JQUERY)
+ await loadScript(ZAM_VIEWER)
+ // La librería es ESM y usa jQuery/ZamModelViewer globales.
+ const mv = await import('wow-model-viewer')
+ if (cancelled || !ref.current) return
+
+ const items = await mv.findItemsInEquipments(
+ equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })),
+ 'live',
+ )
+ if (cancelled || !ref.current) return
+
+ ref.current.id = ref.current.id || `armory-model-${race}-${gender}`
+ await mv.generateModels(1, `#${ref.current.id}`, { race, gender, items }, 'live')
+ if (!cancelled) {
+ clearTimeout(failTimer)
+ setState('ok')
+ }
+ } catch {
+ if (!cancelled) {
+ clearTimeout(failTimer)
+ setState('fail')
+ }
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ clearTimeout(failTimer)
+ }
+ // solo al montar / cambiar de personaje
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [race, gender])
+
+ return (
+
+
+ {state === 'loading' &&
{labels.loading}
}
+ {state === 'fail' &&
{labels.unavailable}
}
+
+ )
+}
diff --git a/web-next/components/ArmorySearchBox.tsx b/web-next/components/ArmorySearchBox.tsx
new file mode 100644
index 0000000..2ccfbde
--- /dev/null
+++ b/web-next/components/ArmorySearchBox.tsx
@@ -0,0 +1,34 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useRouter } from '@/i18n/navigation'
+
+/** Caja de búsqueda de la armería: navega a /armory?tab=&q=. */
+export function ArmorySearchBox({ tab, initialQuery }: { tab: string; initialQuery: string }) {
+ const t = useTranslations('Armory')
+ const router = useRouter()
+ const [q, setQ] = useState(initialQuery)
+
+ function submit(e: React.FormEvent) {
+ e.preventDefault()
+ const query = q.trim()
+ if (query.length < 3) return
+ router.push(`/armory?tab=${tab}&q=${encodeURIComponent(query)}`)
+ }
+
+ return (
+
+ )
+}
diff --git a/web-next/components/SiteHeader.tsx b/web-next/components/SiteHeader.tsx
index f3a7eb2..e4fcf98 100644
--- a/web-next/components/SiteHeader.tsx
+++ b/web-next/components/SiteHeader.tsx
@@ -85,6 +85,9 @@ export function SiteHeader({
{t('header.players')}
)}
+
+ {t('header.armory')}
+
diff --git a/web-next/lib/armory.ts b/web-next/lib/armory.ts
new file mode 100644
index 0000000..b4566ef
--- /dev/null
+++ b/web-next/lib/armory.ts
@@ -0,0 +1,223 @@
+import type { RowDataPacket } from 'mysql2'
+import { db, DB } from './db'
+
+// Armería: búsqueda de personajes/ítems/hermandades y ficha de personaje con equipo.
+// Datos de personaje/equipo en acore_characters; datos de ítem (nombre/calidad/
+// display_id) en django_wow.item_data (importado de los db2; item_template no existe
+// en 3.4.3). Se resuelve en dos pasos para no depender de joins entre esquemas.
+
+export interface CharacterResult {
+ guid: number
+ name: string
+ race: number
+ gender: number
+ class: number
+ level: number
+}
+
+export interface ItemResult {
+ entry: number
+ name: string
+ quality: number
+ itemLevel: number
+ inventoryType: number
+}
+
+export interface GuildResult {
+ guildid: number
+ name: string
+ members: number
+}
+
+export interface EquipItem {
+ slot: number
+ entry: number
+ name: string
+ quality: number
+ itemLevel: number
+ inventoryType: number
+ displayId: number | null
+ transmogEntry: number | null
+ transmogDisplayId: number | null
+}
+
+const LIMIT = 25
+
+export async function searchCharacters(q: string, limit = LIMIT): Promise {
+ try {
+ const [rows] = await db(DB.characters).query(
+ 'SELECT guid, name, race, gender, class, level FROM characters WHERE name LIKE ? ORDER BY level DESC, name LIMIT ?',
+ [`%${q}%`, limit],
+ )
+ return rows.map((r) => ({
+ guid: r.guid,
+ name: r.name,
+ race: r.race,
+ gender: r.gender,
+ class: r.class,
+ level: r.level,
+ }))
+ } catch {
+ return []
+ }
+}
+
+export async function searchGuilds(q: string, limit = LIMIT): Promise {
+ try {
+ const [rows] = await db(DB.characters).query(
+ `SELECT g.guildid, g.name, (SELECT COUNT(*) FROM guild_member gm WHERE gm.guildid = g.guildid) AS members
+ FROM guild g WHERE g.name LIKE ? ORDER BY members DESC LIMIT ?`,
+ [`%${q}%`, limit],
+ )
+ return rows.map((r) => ({ guildid: r.guildid, name: r.name, members: Number(r.members ?? 0) }))
+ } catch {
+ return []
+ }
+}
+
+export async function searchItems(q: string, locale: string, limit = LIMIT): Promise {
+ const col = locale === 'en' ? 'name_en' : 'name_es'
+ try {
+ const [rows] = await db(DB.default).query(
+ `SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type
+ FROM item_data WHERE ${col} LIKE ? OR name_en LIKE ? ORDER BY item_level DESC LIMIT ?`,
+ [`%${q}%`, `%${q}%`, limit],
+ )
+ return rows.map((r) => ({
+ entry: r.entry,
+ name: r.name,
+ quality: r.quality,
+ itemLevel: r.item_level,
+ inventoryType: r.inventory_type,
+ }))
+ } catch {
+ return []
+ }
+}
+
+export async function getCharacter(guid: number): Promise<
+ (CharacterResult & { guildName: string | null; guildId: number | null; online: boolean }) | null
+> {
+ try {
+ const [rows] = await db(DB.characters).query(
+ 'SELECT guid, name, race, gender, class, level, online FROM characters WHERE guid = ?',
+ [guid],
+ )
+ const c = rows[0]
+ if (!c) return null
+ let guildName: string | null = null
+ let guildId: number | null = null
+ try {
+ const [g] = await db(DB.characters).query(
+ `SELECT gu.guildid, gu.name FROM guild_member gm JOIN guild gu ON gu.guildid = gm.guildid WHERE gm.guid = ?`,
+ [guid],
+ )
+ if (g[0]) {
+ guildId = g[0].guildid
+ guildName = g[0].name
+ }
+ } catch {
+ /* sin hermandad */
+ }
+ return {
+ guid: c.guid,
+ name: c.name,
+ race: c.race,
+ gender: c.gender,
+ class: c.class,
+ level: c.level,
+ online: c.online === 1,
+ guildName,
+ guildId,
+ }
+ } catch {
+ return null
+ }
+}
+
+/** Ítems equipados (slots 0-18) del personaje, resueltos con item_data. */
+export async function getCharacterEquipment(guid: number, locale: string): Promise {
+ const col = locale === 'en' ? 'name_en' : 'name_es'
+ try {
+ const [inv] = await db(DB.characters).query(
+ `SELECT ci.slot, ii.itemEntry, ii.transmogrification
+ FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item
+ WHERE ci.guid = ? AND ci.bag = 0 AND ci.slot <= 18 ORDER BY ci.slot`,
+ [guid],
+ )
+ if (inv.length === 0) return []
+
+ // Resolver todos los entries (equipados + transmog) en una consulta a item_data.
+ const entries = [
+ ...new Set(inv.flatMap((r) => [Number(r.itemEntry), Number(r.transmogrification)]).filter((n) => n > 0)),
+ ]
+ const meta = new Map()
+ if (entries.length) {
+ const [items] = await db(DB.default).query(
+ `SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type, display_id
+ FROM item_data WHERE entry IN (${entries.map(() => '?').join(',')})`,
+ entries,
+ )
+ for (const it of items) meta.set(Number(it.entry), it)
+ }
+
+ return inv.map((r) => {
+ const entry = Number(r.itemEntry)
+ const tmog = Number(r.transmogrification) || null
+ const m = meta.get(entry)
+ const tm = tmog ? meta.get(tmog) : undefined
+ return {
+ slot: r.slot,
+ entry,
+ name: m?.name ?? `#${entry}`,
+ quality: m ? Number(m.quality) : 0,
+ itemLevel: m ? Number(m.item_level) : 0,
+ inventoryType: m ? Number(m.inventory_type) : 0,
+ displayId: m && m.display_id != null ? Number(m.display_id) : null,
+ transmogEntry: tmog,
+ transmogDisplayId: tm && tm.display_id != null ? Number(tm.display_id) : null,
+ }
+ })
+ } catch {
+ return []
+ }
+}
+
+export interface GuildMember {
+ guid: number
+ name: string
+ race: number
+ class: number
+ level: number
+ rank: number
+}
+
+export async function getGuild(
+ guildid: number,
+): Promise<{ guildid: number; name: string; members: GuildMember[]; leaderName: string | null } | null> {
+ try {
+ const [g] = await db(DB.characters).query(
+ 'SELECT guildid, name, leaderguid FROM guild WHERE guildid = ?',
+ [guildid],
+ )
+ if (!g[0]) return null
+ const [members] = await db(DB.characters).query(
+ `SELECT c.guid, c.name, c.race, c.class, c.level, gm.rank
+ FROM guild_member gm JOIN characters c ON c.guid = gm.guid
+ WHERE gm.guildid = ? ORDER BY gm.rank, c.level DESC`,
+ [guildid],
+ )
+ const list = members.map((m) => ({
+ guid: m.guid,
+ name: m.name,
+ race: m.race,
+ class: m.class,
+ level: m.level,
+ rank: m.rank,
+ }))
+ const leader = list.find((m) => m.guid === g[0].leaderguid)
+ return { guildid: g[0].guildid, name: g[0].name, members: list, leaderName: leader?.name ?? null }
+ } catch {
+ return null
+ }
+}
diff --git a/web-next/lib/wow-data.ts b/web-next/lib/wow-data.ts
new file mode 100644
index 0000000..fa64886
--- /dev/null
+++ b/web-next/lib/wow-data.ts
@@ -0,0 +1,67 @@
+// Mapas de datos estáticos de WoW (WotLK 3.4.3): razas, clases, slots de equipo.
+// Nombres en ES/EN; el color de clase para la interfaz de la armería.
+
+export const RACES: Record = {
+ 1: { es: 'Humano', en: 'Human', faction: 'alliance' },
+ 2: { es: 'Orco', en: 'Orc', faction: 'horde' },
+ 3: { es: 'Enano', en: 'Dwarf', faction: 'alliance' },
+ 4: { es: 'Elfo de la noche', en: 'Night Elf', faction: 'alliance' },
+ 5: { es: 'No-muerto', en: 'Undead', faction: 'horde' },
+ 6: { es: 'Tauren', en: 'Tauren', faction: 'horde' },
+ 7: { es: 'Gnomo', en: 'Gnome', faction: 'alliance' },
+ 8: { es: 'Trol', en: 'Troll', faction: 'horde' },
+ 10: { es: 'Elfo de sangre', en: 'Blood Elf', faction: 'horde' },
+ 11: { es: 'Draenei', en: 'Draenei', faction: 'alliance' },
+}
+
+export const CLASSES: Record = {
+ 1: { es: 'Guerrero', en: 'Warrior', color: '#C79C6E' },
+ 2: { es: 'Paladín', en: 'Paladin', color: '#F58CBA' },
+ 3: { es: 'Cazador', en: 'Hunter', color: '#ABD473' },
+ 4: { es: 'Pícaro', en: 'Rogue', color: '#FFF569' },
+ 5: { es: 'Sacerdote', en: 'Priest', color: '#FFFFFF' },
+ 6: { es: 'Caballero de la Muerte', en: 'Death Knight', color: '#C41F3B' },
+ 7: { es: 'Chamán', en: 'Shaman', color: '#0070DE' },
+ 8: { es: 'Mago', en: 'Mage', color: '#69CCF0' },
+ 9: { es: 'Brujo', en: 'Warlock', color: '#9482C9' },
+ 11: { es: 'Druida', en: 'Druid', color: '#FF7D0A' },
+}
+
+// Slots de equipo 0-18 (EQUIPMENT_SLOT_*). Los que no se muestran (camisa 3, tabardo 18)
+// se dejan igual por completitud del modelo 3D.
+export const EQUIP_SLOTS: { slot: number; es: string; en: string }[] = [
+ { slot: 0, es: 'Cabeza', en: 'Head' },
+ { slot: 1, es: 'Cuello', en: 'Neck' },
+ { slot: 2, es: 'Hombros', en: 'Shoulders' },
+ { slot: 14, es: 'Espalda', en: 'Back' },
+ { slot: 4, es: 'Pecho', en: 'Chest' },
+ { slot: 3, es: 'Camisa', en: 'Shirt' },
+ { slot: 18, es: 'Tabardo', en: 'Tabard' },
+ { slot: 8, es: 'Muñecas', en: 'Wrists' },
+ { slot: 9, es: 'Manos', en: 'Hands' },
+ { slot: 5, es: 'Cintura', en: 'Waist' },
+ { slot: 6, es: 'Piernas', en: 'Legs' },
+ { slot: 7, es: 'Pies', en: 'Feet' },
+ { slot: 10, es: 'Anillo', en: 'Ring' },
+ { slot: 11, es: 'Anillo', en: 'Ring' },
+ { slot: 12, es: 'Abalorio', en: 'Trinket' },
+ { slot: 13, es: 'Abalorio', en: 'Trinket' },
+ { slot: 15, es: 'Mano derecha', en: 'Main Hand' },
+ { slot: 16, es: 'Mano izquierda', en: 'Off Hand' },
+ { slot: 17, es: 'A distancia', en: 'Ranged' },
+]
+
+export function raceName(id: number, locale: string): string {
+ const r = RACES[id]
+ return r ? (locale === 'en' ? r.en : r.es) : `#${id}`
+}
+export function className(id: number, locale: string): string {
+ const c = CLASSES[id]
+ return c ? (locale === 'en' ? c.en : c.es) : `#${id}`
+}
+export function classColor(id: number): string {
+ return CLASSES[id]?.color ?? '#ffffff'
+}
+export function factionOf(race: number): 'alliance' | 'horde' | null {
+ return RACES[race]?.faction ?? null
+}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index 5cbcb58..a975dd7 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -2000,5 +2000,38 @@
"errConfirm": "The target character name does not match the confirmation.",
"successMsg": "Gift sent to {character}! They will receive it in their in-game mail."
}
+ },
+ "header": {
+ "armory": "Armory"
+ },
+ "Armory": {
+ "title": "Armory",
+ "searchPlaceholder": "Search character, item or guild…",
+ "search": "Search",
+ "tabCharacters": "Characters",
+ "tabItems": "Items",
+ "tabGuilds": "Guilds",
+ "noResults": "No results.",
+ "searchHint": "Type at least 3 characters.",
+ "level": "Level",
+ "itemLevel": "Item level",
+ "members": "Members",
+ "equipment": "Equipment",
+ "guild": "Guild",
+ "race": "Race",
+ "class": "Class",
+ "faction": "Faction",
+ "alliance": "Alliance",
+ "horde": "Horde",
+ "online": "Online",
+ "offline": "Offline",
+ "viewProfile": "View profile",
+ "model3d": "3D model",
+ "modelLoading": "Loading model…",
+ "modelUnavailable": "The 3D model is not available.",
+ "rank": "Rank",
+ "leader": "Leader",
+ "noEquipment": "No visible equipment.",
+ "backToArmory": "Back to armory"
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index 578ad2b..1aad777 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -2000,5 +2000,38 @@
"errConfirm": "El nombre del personaje destino no coincide con la confirmación.",
"successMsg": "¡Regalo enviado a {character}! Lo recibirá en su correo del juego."
}
+ },
+ "header": {
+ "armory": "Armería"
+ },
+ "Armory": {
+ "title": "Armería",
+ "searchPlaceholder": "Buscar personaje, ítem o hermandad…",
+ "search": "Buscar",
+ "tabCharacters": "Personajes",
+ "tabItems": "Ítems",
+ "tabGuilds": "Hermandades",
+ "noResults": "Sin resultados.",
+ "searchHint": "Escribe al menos 3 caracteres.",
+ "level": "Nivel",
+ "itemLevel": "Nivel de objeto",
+ "members": "Miembros",
+ "equipment": "Equipo",
+ "guild": "Hermandad",
+ "race": "Raza",
+ "class": "Clase",
+ "faction": "Facción",
+ "alliance": "Alianza",
+ "horde": "Horda",
+ "online": "Conectado",
+ "offline": "Desconectado",
+ "viewProfile": "Ver ficha",
+ "model3d": "Modelo 3D",
+ "modelLoading": "Cargando modelo…",
+ "modelUnavailable": "El modelo 3D no está disponible.",
+ "rank": "Rango",
+ "leader": "Líder",
+ "noEquipment": "Sin equipo visible.",
+ "backToArmory": "Volver a la armería"
}
}
diff --git a/web-next/package-lock.json b/web-next/package-lock.json
index 01819cb..8e2b8d3 100644
--- a/web-next/package-lock.json
+++ b/web-next/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "web-next",
"version": "0.1.0",
+ "hasInstallScript": true,
"dependencies": {
"@next/third-parties": "^16.2.10",
"@tinymce/tinymce-react": "^6.3.0",
@@ -21,7 +22,8 @@
"react-dom": "19.2.4",
"sanitize-html": "^2.17.6",
"stripe": "^22.3.1",
- "tinymce": "^8.8.0"
+ "tinymce": "^8.8.0",
+ "wow-model-viewer": "^1.5.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
@@ -7998,6 +8000,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/wow-model-viewer": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/wow-model-viewer/-/wow-model-viewer-1.5.3.tgz",
+ "integrity": "sha512-henv8+Cnu3MwuAWjBGe3Zg1mfMcbD2kIx4E4fm0nMEodvBO/9+Gfe8RrLS/5SKjtYLTG7Rm2OtDlkQBvM00JRw==",
+ "engines": {
+ "node": ">=16.0.0 <19.0.0"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
diff --git a/web-next/package.json b/web-next/package.json
index 43a66ce..018c763 100644
--- a/web-next/package.json
+++ b/web-next/package.json
@@ -23,7 +23,8 @@
"react-dom": "19.2.4",
"sanitize-html": "^2.17.6",
"stripe": "^22.3.1",
- "tinymce": "^8.8.0"
+ "tinymce": "^8.8.0",
+ "wow-model-viewer": "^1.5.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",