From 6337de73adc79a123d9e95d07876bc821d78b7a2 Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 10:58:36 +0000 Subject: [PATCH] =?UTF-8?q?web-next:=20/account=20id=C3=A9ntico=20al=20my-?= =?UTF-8?q?account=20de=20Django?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La página de cuenta no se parecía a la de Django (oro descuadrado, info como tabla, herramientas en grupos abiertos). Reescrita para replicar el partials/my-account.html + isla AccountDashboard de Django: - Información en DOS fieldsets (.account-fieldset): «Datos básicos» (cuentas, correos, fecha de registro, IPs) y «Estado de la cuenta» (insignia de rango nw-rank + PD/PV/créditos/token + baneo + reclutamiento). lib/account.ts ampliado para traer reg_mail/email/joindate/last_ip/last_attempt_ip, estado de baneo (account_banned) y nº de reclutados. - Personajes en char-box con el markup exacto de Django: retrato flotante + oro EN LÍNEA (monedas width=10). Antes las monedas salían a tamaño nativo (descuadre) y apiladas. - Herramientas en paneles plegables (acordeón) «OPCIONES DE CUENTA / OPCIONES DE PERSONAJE» como Django (componente cliente AccountTools), con los servicios ya implementados en Next y las etiquetas/descripciones exactas. - globals.css: revertir otro reset del preflight de Tailwind — img{display: block} apilaba verticalmente los iconos en línea (monedas); el tema los usa inline como la web Django. Volvemos a img{display:inline}. Verificado con Playwright (login real con cuenta de prueba + captura): info, personajes con oro en línea y paneles que expanden con sus iconos; home sin regresión por el cambio de img. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/account/page.tsx | 241 ++++++++----------------- web-next/app/globals.css | 8 + web-next/components/AccountTools.tsx | 93 ++++++++++ web-next/lib/account.ts | 70 +++++++ web-next/messages/en.json | 43 +++-- web-next/messages/es.json | 53 ++++-- 6 files changed, 311 insertions(+), 197 deletions(-) create mode 100644 web-next/components/AccountTools.tsx diff --git a/web-next/app/[locale]/account/page.tsx b/web-next/app/[locale]/account/page.tsx index 9b72eef..bbfb97e 100644 --- a/web-next/app/[locale]/account/page.tsx +++ b/web-next/app/[locale]/account/page.tsx @@ -1,14 +1,14 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' -import { redirect, Link } from '@/i18n/navigation' +import { redirect } from '@/i18n/navigation' import { getSession } from '@/lib/session' import { getAccountDashboard } from '@/lib/account' import { isAdmin } from '@/lib/admin' import { PageShell } from '@/components/PageShell' +import { AccountTools } from '@/components/AccountTools' export const dynamic = 'force-dynamic' -// Grupos de servicios con su icono del sprite real (acc-icon ). -type ToolItem = { href: string; icon: string; label: string; desc: string } +const ICONS = '/nw-themes/nw-ryu/nw-images/nw-icons' export default async function AccountPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params @@ -22,186 +22,99 @@ export default async function AccountPage({ params }: { params: Promise<{ locale const data = await getAccountDashboard(session) const admin = await isAdmin(session) - const groups: { title: string; items: ToolItem[] }[] = [ - { - title: t('accountSettings'), - items: [ - { href: '/change-password', icon: 'password-icon', label: t('svcChangePassword'), desc: t('svcChangePasswordDesc') }, - { href: '/change-email', icon: 'email-icon', label: t('svcChangeEmail'), desc: t('svcChangeEmailDesc') }, - { href: '/security-token', icon: 'token-icon', label: t('svcToken'), desc: t('svcTokenDesc') }, - ], - }, - { - title: t('servicesTitle'), - items: [ - { href: '/rename', icon: 'rename-icon', label: t('svcRename'), desc: t('svcRenameDesc') }, - { href: '/customize', icon: 'customize-icon', label: t('svcCustomize'), desc: t('svcCustomizeDesc') }, - { href: '/change-race', icon: 'change-race-icon', label: t('svcChangeRace'), desc: t('svcChangeRaceDesc') }, - { href: '/change-faction', icon: 'change-faction-icon', label: t('svcChangeFaction'), desc: t('svcChangeFactionDesc') }, - { href: '/level-up', icon: 'level-up-icon', label: t('svcLevelUp'), desc: t('svcLevelUpDesc') }, - { href: '/gold', icon: 'gold-icon', label: t('svcGold'), desc: t('svcGoldDesc') }, - { href: '/transfer', icon: 'tranfer-char-icon', label: t('svcTransfer'), desc: t('svcTransferDesc') }, - ], - }, - { - title: t('utilitiesTitle'), - items: [ - { href: '/revive', icon: 'revive-icon', label: t('svcRevive'), desc: t('svcReviveDesc') }, - { href: '/unstuck', icon: 'unstuck-icon', label: t('svcUnstuck'), desc: t('svcUnstuckDesc') }, - ], - }, - { - title: t('communityTitle'), - items: [ - { href: '/vote-points', icon: 'vote-icon', label: t('svcVote'), desc: t('svcVoteDesc') }, - { href: '/recruit', icon: 'recruit-a-friend-icon', label: t('svcRecruit'), desc: t('svcRecruitDesc') }, - { href: '/battlepay', icon: 'store-icon', label: t('svcStore'), desc: t('svcStoreDesc') }, - ], - }, - ] - return ( - {/* Información de la cuenta */} + {/* Información: dos fieldsets como en Django */}
-
+

{t('infoTitle')}

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
{t('bnetAccount')}{data.bnetEmail}
{t('gameAccount')}{data.gameAccount}
{t('dp')}{data.dp}
{t('vp')}{data.vp}
{t('battlepayCredits')}{data.battlepayCredits}
{t('securityToken')} - {data.securityTokenDate ? ( - - {t('requested')} — {new Date(data.securityTokenDate).toLocaleString(locale)} - - ) : ( - {t('notRequested')} - )} -
+
+ {t('basicData')} +
+

{t('bnetAccount')}: {data.bnetEmail}

+

{t('gameAccount')}: {data.gameAccount}

+

{t('regMail')}: {data.regMail}

+

{t('currentMail')}: {data.email}

+

{t('joindate')}: {data.joindate}

+

{t('lastIpWeb')}: {data.lastIp}

+

{t('lastIpServer')}: {data.lastAttemptIp}

+
+
+ +
+ {t('accountState')} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + rank +

{t('dp')}: {data.dp}

+

{t('vp')}: {data.vp}

+

{t('battlepayCredits')}: {data.battlepayCredits}

+

+ {t('securityToken')}:{' '} + + {data.securityTokenDate + ? `${t('requested')} - ${new Date(data.securityTokenDate).toLocaleString(locale)}` + : t('notRequested')} + +

+ {data.isBanned ? ( + <> +

{t('banned')}: {t('yes')}

+

{t('banEnd')}: {data.unbanDate}

+ + ) : ( + <> +

{t('banned')}: {t('no')}

+

{t('recruited')}: {data.isRecruited ? t('yes') : t('no')}

+

{t('recruitedFriends')}: {data.recruitedCount}

+ + )} +
+
- {/* Personajes */} -
-
-

{t('characters')}

-
-
- {data.characters.length === 0 ? ( -

{t('noCharacters')}

- ) : ( - data.characters.map((c) => ( -
+ {/* Mis personajes: rejilla con char-box como en Django */} + {data.characters.length > 0 && ( +
+
+

{t('characters')}

+
+
+ {data.characters.map((c) => ( +
{/* eslint-disable-next-line @next/next/no-img-element */} - {c.raceName} - {c.name} -
- - {t('level')} {c.level} · {c.raceName} {c.className} - -
- 📍 {c.zone} -
- - {c.gold} - {/* eslint-disable-next-line @next/next/no-img-element */} - g {c.silver} - {/* eslint-disable-next-line @next/next/no-img-element */} - s {c.copper} - {/* eslint-disable-next-line @next/next/no-img-element */} - c - + {c.name} +

{c.name}

+

{t('level')} {c.level}

+

{c.zone}

+

+ + {/* eslint-disable-next-line @next/next/no-img-element */} + {c.gold}g{' '} + {/* eslint-disable-next-line @next/next/no-img-element */} + {c.silver}s{' '} + {/* eslint-disable-next-line @next/next/no-img-element */} + {c.copper}c + +

- )) - )} + ))} +
-
+ )} - {/* Utilidades y Herramientas */} + {/* Utilidades y Herramientas: paneles plegables como en Django */}
-
+

{t('toolsTitle')}

-
- {admin && ( - -
-
-
- 🛡️ {t('adminPanel')} -
- {t('adminPanelDesc')} -
-
- - )} - {groups.map((g) => ( -
-
-

{g.title}

-
- - - {rows(g.items).map((pair, i) => ( - - {pair.map((it) => ( - - ))} - {pair.length === 1 && - ))} - -
- -
-
-
- {it.label} -
- {it.desc} -
-
- -
} -
-
- ))} -
+
) } - -// Agrupa los items de dos en dos (2 columnas, como el my-account de Django). -function rows(items: T[]): T[][] { - const out: T[][] = [] - for (let i = 0; i < items.length; i += 2) out.push(items.slice(i, i + 2)) - return out -} diff --git a/web-next/app/globals.css b/web-next/app/globals.css index 538cac1..e00a4dc 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -39,6 +39,14 @@ select { table { border-collapse: separate; } +/* 3) Tailwind pone img{display:block}, lo que apila verticalmente los iconos + en línea (monedas de oro/plata/cobre, iconos inline del tema). El tema los + usa inline (como la web Django). Volvemos a inline; las imágenes que deban + ser bloque ya lo fijan por su propia clase (.img-med-icon, etc., mayor + especificidad). */ +img { + display: inline; +} /* Componentes reutilizables */ @layer components { diff --git a/web-next/components/AccountTools.tsx b/web-next/components/AccountTools.tsx new file mode 100644 index 0000000..0b41e08 --- /dev/null +++ b/web-next/components/AccountTools.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' + +type Tool = { href: string; icon: string; label: string; desc: string } + +/** Replica los paneles plegables «OPCIONES DE CUENTA / OPCIONES DE PERSONAJE» + * del my-account de Django (acordeón por click), con las herramientas ya + * implementadas en Next. */ +export function AccountTools({ isAdmin }: { isAdmin: boolean }) { + const t = useTranslations('Account') + + const accountTools: Tool[] = [ + { href: '/change-password', icon: 'password-icon', label: t('svcChangePassword'), desc: t('svcChangePasswordDesc') }, + { href: '/change-email', icon: 'email-icon', label: t('svcChangeEmail'), desc: t('svcChangeEmailDesc') }, + { href: '/security-token', icon: 'token-icon', label: t('svcToken'), desc: t('svcTokenDesc') }, + { href: '/recruit', icon: 'recruit-a-friend-icon', label: t('svcRecruit'), desc: t('svcRecruitDesc') }, + { href: '/vote-points', icon: 'vote-icon', label: t('svcVote'), desc: t('svcVoteDesc') }, + ] + const characterTools: Tool[] = [ + { href: '/unstuck', icon: 'unstuck-icon', label: t('svcUnstuck'), desc: t('svcUnstuckDesc') }, + { href: '/revive', icon: 'revive-icon', label: t('svcRevive'), desc: t('svcReviveDesc') }, + { href: '/rename', icon: 'rename-icon', label: t('svcRename'), desc: t('svcRenameDesc') }, + { href: '/customize', icon: 'customize-icon', label: t('svcCustomize'), desc: t('svcCustomizeDesc') }, + { href: '/change-race', icon: 'change-race-icon', label: t('svcChangeRace'), desc: t('svcChangeRaceDesc') }, + { href: '/change-faction', icon: 'change-faction-icon', label: t('svcChangeFaction'), desc: t('svcChangeFactionDesc') }, + { href: '/level-up', icon: 'level-up-icon', label: t('svcLevelUp'), desc: t('svcLevelUpDesc') }, + { href: '/gold', icon: 'gold-icon', label: t('svcGold'), desc: t('svcGoldDesc') }, + { href: '/transfer', icon: 'tranfer-char-icon', label: t('svcTransfer'), desc: t('svcTransferDesc') }, + { href: '/battlepay', icon: 'store-icon', label: t('svcStore'), desc: t('svcStoreDesc') }, + ] + + return ( +
+ {isAdmin && ( + +
+
+
+ 🛡️ {t('adminPanel')} +
+ {t('adminPanelDesc')} +
+
+ + )} + + +
+ ) +} + +function Panel({ id, arrow, title, tools }: { id: string; arrow: string; title: string; tools: Tool[] }) { + const [open, setOpen] = useState(false) + const rows: Tool[][] = [] + for (let i = 0; i < tools.length; i += 2) rows.push(tools.slice(i, i + 2)) + + return ( + <> +
setOpen((v) => !v)}> + {title} +
+
+ + + {rows.map((pair, i) => ( + + {pair.map((it) => ( + + ))} + {pair.length === 1 && + ))} + +
+ +
+
+
+ {it.label} +
+ {it.desc} +
+
+ +
} +
+
+
+ + ) +} diff --git a/web-next/lib/account.ts b/web-next/lib/account.ts index 48fa7f0..91314d9 100644 --- a/web-next/lib/account.ts +++ b/web-next/lib/account.ts @@ -21,10 +21,19 @@ export interface Character { export interface AccountDashboard { bnetEmail: string gameAccount: string + regMail: string + email: string + joindate: string + lastIp: string + lastAttemptIp: string dp: number vp: number battlepayCredits: number securityTokenDate: string | null + isBanned: boolean + unbanDate: string | null + isRecruited: boolean + recruitedCount: number characters: Character[] } @@ -58,6 +67,58 @@ export async function getAccountDashboard(session: SessionData): Promise( + 'SELECT reg_mail, email, last_ip, last_attempt_ip, joindate, recruiter FROM account WHERE id = ?', + [accountId], + ) + if (a[0]) { + regMail = a[0].reg_mail || '' + email = a[0].email || '' + lastIp = a[0].last_ip || '' + lastAttemptIp = a[0].last_attempt_ip || '' + isRecruited = Boolean(a[0].recruiter) + if (a[0].joindate) joindate = new Date(a[0].joindate).toLocaleString('es-ES') + } + } catch { + /* ignore */ + } + + // Estado de baneo (account_banned activo). + let isBanned = false + let unbanDate: string | null = null + try { + const [bn] = await db(DB.auth).query( + 'SELECT unbandate FROM account_banned WHERE id = ? AND active = 1 LIMIT 1', + [accountId], + ) + if (bn[0]) { + isBanned = true + unbanDate = new Date(Number(bn[0].unbandate) * 1000).toLocaleString('es-ES') + } + } catch { + /* ignore */ + } + + // Nº de amigos reclutados. + let recruitedCount = 0 + try { + const [rc] = await db(DB.auth).query( + 'SELECT COUNT(*) AS n FROM account WHERE recruiter = ?', + [accountId], + ) + if (rc[0]) recruitedCount = Number(rc[0].n) + } catch { + /* ignore */ + } + let securityTokenDate: string | null = null try { const [s] = await db(DB.default).query( @@ -96,10 +157,19 @@ export async function getAccountDashboard(session: SessionData): Promise