From c3e57ff25e9faaa78287f64e1824d6792e19e457 Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 16:14:10 +0000 Subject: [PATCH] =?UTF-8?q?web-next:=20/recruit=20seg=C3=BAn=20el=20markup?= =?UTF-8?q?=20(Informaci=C3=B3n=20+=20Panel=20RAF=20con=20recompensas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reescrita la página: box «Información» con la guía completa de Recluta a un amigo (marca → nombre del reino) + box «Panel de Recluta a un amigo». - Nuevo RecruitPanel: estadísticas (cuenta reclutada, amigos reclutados, amigos al nivel 80, recompensas reclamadas X/6) + tabla de recompensas (objetivo, objeto con icono y color de calidad, estado) con botón Reclamar para las disponibles + sección «Recompensas disponibles». Sustituye a RecruitClaim. - lib/recruit-claim: getRecruitStats (isRecruited + recruitedCount). - Colores de calidad de objeto .q0-.q5 en globals.css. - Sembradas las 6 recompensas en home_recruitreward (seed en sql/). Co-Authored-By: Claude Opus 4.8 (1M context) --- sql/seed_recruit_rewards.sql | 10 ++ web-next/app/[locale]/recruit/page.tsx | 117 +++++++++++++++++-- web-next/app/globals.css | 8 ++ web-next/components/RecruitClaim.tsx | 106 ----------------- web-next/components/RecruitPanel.tsx | 152 +++++++++++++++++++++++++ web-next/lib/recruit-claim.ts | 15 +++ 6 files changed, 294 insertions(+), 114 deletions(-) create mode 100644 sql/seed_recruit_rewards.sql delete mode 100644 web-next/components/RecruitClaim.tsx create mode 100644 web-next/components/RecruitPanel.tsx diff --git a/sql/seed_recruit_rewards.sql b/sql/seed_recruit_rewards.sql new file mode 100644 index 0000000..2296f73 --- /dev/null +++ b/sql/seed_recruit_rewards.sql @@ -0,0 +1,10 @@ +-- Recompensas de Recluta a un amigo (home_recruitreward) para el panel de /recruit. +-- Aplicar: mysql django_wow < sql/seed_recruit_rewards.sql +SET NAMES utf8mb4; +INSERT INTO home_recruitreward (required_friends, reward_name, item_id, item_quantity, item_link, icon_class) VALUES +(1,'Medallón de oro',37297,1,'https://wotlk.ultimowow.com/?item=37297','q3 inv_jewelry_amulet_03'), +(2,'Emblema de escarcha',49426,30,'https://wotlk.ultimowow.com/?item=49426','q4 inv_misc_frostemblem_01'), +(4,'Zhebra presta',37719,1,'https://wotlk.ultimowow.com/?item=37719','q4 ability_mount_charger'), +(6,'Ónice encantado',48527,1,'https://wotlk.ultimowow.com/?item=48527','q3 inv_misc_qirajicrystal_05'), +(8,'Emblema de escarcha',49426,60,'https://wotlk.ultimowow.com/?item=49426','q4 inv_misc_frostemblem_01'), +(10,'Cohete de paseo X-53',54860,1,'https://wotlk.ultimowow.com/?item=54860','q4 ability_mount_rocketmount2'); diff --git a/web-next/app/[locale]/recruit/page.tsx b/web-next/app/[locale]/recruit/page.tsx index 7287c8d..8bcd996 100644 --- a/web-next/app/[locale]/recruit/page.tsx +++ b/web-next/app/[locale]/recruit/page.tsx @@ -2,9 +2,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { Link } from '@/i18n/navigation' import { getSession } from '@/lib/session' import { getGameCharacters } from '@/lib/characters' -import { getRecruitRewards, getRecruitedLevel80Count } from '@/lib/recruit-claim' +import { getRecruitRewards, getRecruitedLevel80Count, getRecruitStats } from '@/lib/recruit-claim' +import { getRealmName } from '@/lib/realm' import { PageShell } from '@/components/PageShell' -import { RecruitClaim } from '@/components/RecruitClaim' +import { RecruitPanel } from '@/components/RecruitPanel' export const dynamic = 'force-dynamic' @@ -13,20 +14,120 @@ export default async function RecruitPage({ params }: { params: Promise<{ locale setRequestLocale(locale) const t = await getTranslations('RecruitClaim') const session = await getSession() + const realm = await getRealmName() - const rewards = await getRecruitRewards(session.accountId ?? null) + const loggedIn = Boolean(session.accountId) + const rewards = loggedIn ? await getRecruitRewards(session.accountId!) : [] + const level80Count = loggedIn ? await getRecruitedLevel80Count(session.accountId!) : 0 + const stats = loggedIn ? await getRecruitStats(session.accountId!) : { isRecruited: false, recruitedCount: 0 } + const characters = loggedIn ? (await getGameCharacters(session.accountId!)).map((c) => c.name) : [] return (
+
+

Información

+ Regresar a mi cuenta + Regresar +
-

{t('intro')}


- {session.accountId ? ( - ¿Qué es Recluta a un amigo? +

Recluta a un amigo (También conocido como RAF) es un sistema en el cual un usuario de {realm} puede registrarse como referido/reclutado de otro usuario y así disfrutar de beneficios y varios premios para el usuario que ha referido/reclutado a otros.

+

Sólo funciona para usuarios nuevos en el servidor y no tiene límites de hasta cuántas cuentas pueden reclutarse.

+

Los beneficios funcionan para todos los personajes de cada cuenta y permanece para siempre.

+
+

- Quien se crea una cuenta completando el campo Personaje reclutante en el registro, es el referido/reclutante.

+

- El personaje que se haya utilizado para crear la cuenta, es el referente/reclutante.

+
+
+
+ ¿Cómo reclutar a un amigo? +

Cuando tu amigo cree una cuenta, dile que en el campo 'Personaje reclutante' escriba el nombre de alguno de los personajes en tu cuenta.

+

Una vez registrado como referido, ya podrán disfrutar de todos los beneficios.

+

Asegúrate de elegir bien a tu referente y/o referido ya que la acción no es reversible.

+
+
+
+ ¿Cómo obtener los beneficios? +

- Ambos personajes tienen que estar leveando en grupo. Recuerda que deben estar ambos en lista de amigos para darse grupo.

+

- Deben estar en una distancia relativamente cercana.

+

- La diferencia máxima entre ambos jugadores debe ser de 4 niveles.

+
+

Si no se cumple alguna de las condiciones mencionadas, no se recibirá el bonus de experiencia.

+
+
+
+ ¿Cómo ver la experiencia obtenida? +

Podrás ver el bonus de experiencia recibida activando los mensajes de experiencia en el chat:

+

1) Clic derecho en la pestaña de chat General.

+

2) Clic izquierdo en Configuración.

+

3) Clic izquierdo en Otros.

+

4) Marca la casilla Experiencia.

+

5) Clic izquierdo en Aceptar.

+
+

Al ganar experiencia, verás un mensaje detallado que muestra la cantidad obtenida y cuánto de ello proviene del bonus de Recluta a un amigo.

+
+
+
+ ¿Puedo desactivar el bonus de experiencia? +

El bonus será otorgado siempre y cuando se cumplan los requisitos.

+

Si deseas dejar de obtenerlo, asegúrate de no cumplir alguna de las condiciones mencionadas en «¿Cómo obtener los beneficios?».

+
+
+
+ ¿Hay recompensas por reclutar amigos? +

Existen varias recompensas por reclutar amigos.

+

En el recuadro debajo podrás ver cuáles son los objetivos de cada recompensa. (Debes estar conectado para ver el recuadro).

+
+
+
+ ¿Cuáles son los beneficios? +

Cuando un personaje del referente y un personaje del referido levean juntos, serán beneficiados por:

+

Hasta nivel 60:

+

- La habilidad de 'Invocar amigo'.

+

- El personaje referido podrá dar niveles al personaje referente.

+
+

Hasta nivel 80:

+

- Recibirán experiencia x2.

+

- Un bonus de reputación.

+
+
+
+ Invocar amigo +

Esta habilidad aparece en 2 lugares:

+

- Como opción al hacer click derecho en el avatar del personaje referente/referido.

+

- Si tienes al personaje referente/referido en la lista de amigos, aparece un ícono especial a la derecha de su nombre.

+
+

Al usar esta habilidad se debe tener en target al personaje referente/referido y no debe ser modificado hasta finalizar su casteo.

+

Su tiempo de reutilización es de 1 hora.

+
+
+
+ Otorgar niveles +

Esta habilidad aparece al hacer click derecho en el avatar del personaje referente y puede utilizarla solamente el personaje referido.

+

Cuando el personaje referido tenga al menos 1 nivel más que el personaje referente, puede otorgar un nivel que deberá ser aceptado por el personaje referente.

+

Para volver a otorgar un nivel, el personaje referido tendrá que subir 2 niveles más.

+
+
+
+ ¿Tienes dudas o problemas para reclutar amigos? +

Si a pesar de leer este instructivo aún tienes dudas, el equipo de {realm} podrá asesorarte en cualquier momento. Puedes colocar un ticket en el servidor solicitando ayuda para Reclutar a un amigo.

+
+
+ +
+ +
+

Panel de Recluta a un amigo

+
+ {loggedIn ? ( + c.name)} - level80Count={await getRecruitedLevel80Count(session.accountId)} + characters={characters} + level80Count={level80Count} + isRecruited={stats.isRecruited} + recruitedCount={stats.recruitedCount} /> ) : (

diff --git a/web-next/app/globals.css b/web-next/app/globals.css index 85ccd3b..78cc6b3 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -399,6 +399,14 @@ textarea:focus { margin-top: 4px; } +/* Colores de calidad de objeto (enlaces de recompensas RAF, etc.). */ +.q0 { color: #9d9d9d; } +.q1 { color: #ffffff; } +.q2 { color: #1eff00; } +.q3 { color: #0070dd; } +.q4 { color: #a335ee; } +.q5 { color: #ff8000; } + /* Colores de clase de WoW (nombre de personaje) */ .class-warrior { color: #c79c6e; } .class-paladin { color: #f58cba; } diff --git a/web-next/components/RecruitClaim.tsx b/web-next/components/RecruitClaim.tsx deleted file mode 100644 index 54b3e4e..0000000 --- a/web-next/components/RecruitClaim.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client' - -import { useState } from 'react' -import { useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' -import type { RecruitRewardView } from '@/lib/recruit-claim' - -export function RecruitClaim({ - rewards, - characters, - level80Count, -}: { - rewards: RecruitRewardView[] - characters: string[] - level80Count: number -}) { - const t = useTranslations('RecruitClaim') - const router = useRouter() - const [character, setCharacter] = useState(characters[0] ?? '') - const [busyId, setBusyId] = useState(null) - const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null) - - async function claim(reward: RecruitRewardView) { - if (busyId !== null || !character) return - setBusyId(reward.id) - setMsg(null) - try { - const res = await fetch('/api/recruit/claim', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'same-origin', - body: JSON.stringify({ rewardId: reward.id, character }), - }) - const d: { success?: boolean; message?: string } = await res.json() - setMsg({ ok: Boolean(d.success), text: t(d.message ?? 'deliveryError', { reward: reward.reward_name, character }) }) - if (d.success) router.refresh() - } catch { - setMsg({ ok: false, text: t('deliveryError') }) - } finally { - setBusyId(null) - } - } - - return ( -

-
-

- {t('level80Friends')}: {level80Count} -

- {characters.length > 0 ? ( -

- {t('deliverTo')} - -

- ) : ( -

{t('noCharacters')}

- )} -
- -
- {msg && {msg.text}} -
- - {rewards.length === 0 ? ( -

{t('noRewards')}

- ) : ( -
- {rewards.map((r) => { - const eligible = level80Count >= r.required_friends - return ( -
- - {r.reward_name} - -

- {t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity} -

-
- {r.claimed ? ( - - {t('claimed')} - - ) : ( - - )} -
- ) - })} -
- )} -
- ) -} diff --git a/web-next/components/RecruitPanel.tsx b/web-next/components/RecruitPanel.tsx new file mode 100644 index 0000000..1e36769 --- /dev/null +++ b/web-next/components/RecruitPanel.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useState } from 'react' +import type { RecruitRewardView } from '@/lib/recruit-claim' + +const ICON_BASE = 'https://wotlk.ultimowow.com/static/images/wow/icons/tiny' + +function claimError(message?: string): string { + switch (message) { + case 'requirementsNotMet': + return 'Aún no cumples los requisitos para esta recompensa.' + case 'alreadyClaimed': + return 'Esta recompensa ya fue reclamada.' + case 'invalidCharacter': + return 'Personaje no válido.' + case 'notFound': + return 'Recompensa no encontrada.' + default: + return 'No se pudo reclamar la recompensa. Inténtalo de nuevo.' + } +} + +/** + * Panel de Recluta a un amigo: estadísticas de la cuenta + tabla de recompensas + * (objetivo, objeto y estado) con botón para reclamar las disponibles. + */ +export function RecruitPanel({ + rewards, + characters, + level80Count, + isRecruited, + recruitedCount, +}: { + rewards: RecruitRewardView[] + characters: string[] + level80Count: number + isRecruited: boolean + recruitedCount: number +}) { + const [character, setCharacter] = useState(characters[0] ?? '') + const [busyId, setBusyId] = useState(null) + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null) + + const claimedCount = rewards.filter((r) => r.claimed).length + const available = rewards.filter((r) => !r.claimed && level80Count >= r.required_friends) + + async function claim(reward: RecruitRewardView) { + if (busyId !== null || !character) return + setBusyId(reward.id) + setMsg(null) + try { + const res = await fetch('/api/recruit/claim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ rewardId: reward.id, character }), + }) + const d: { success?: boolean; message?: string } = await res.json() + if (d.success) { + window.location.reload() + return + } + setMsg({ ok: false, text: claimError(d.message) }) + setBusyId(null) + } catch { + setMsg({ ok: false, text: claimError() }) + setBusyId(null) + } + } + + function rewardCell(r: RecruitRewardView) { + const [quality, icon] = (r.icon_class || '').split(' ') + return ( + <> + + {r.reward_name} + + {r.item_quantity > 1 ? ` x${r.item_quantity}` : ''} + + ) + } + + return ( + <> +

Cuenta reclutada: {isRecruited ? 'Sí' : 'No'}

+

Amigos reclutados: {recruitedCount}

+

Amigos reclutados al nivel 80: {level80Count}

+

Recompensas reclamadas: {claimedCount}/{rewards.length}

+
+ + {characters.length > 0 && ( +

+ Recibir recompensas en:{' '} + +

+ )} + {msg &&

{msg.text}

} + + + + + + + + + {rewards.map((r) => { + const eligible = level80Count >= r.required_friends + return ( + + + + + + ) + })} + +
Amigos reclutadosRecompensaEstado
{r.required_friends} {r.required_friends === 1 ? 'amigo' : 'amigos'} al nivel 80{rewardCell(r)} + {r.claimed ? ( + Reclamado + ) : eligible ? ( + + ) : ( + Sin reclamar + )} +
+ +
+
+

RECOMPENSAS DISPONIBLES

+
+ {available.length === 0 ? ( + Recluta amigos para poder reclamar las recompensas + ) : ( + + Tienes {available.length} recompensa{available.length === 1 ? '' : 's'} lista{available.length === 1 ? '' : 's'} para reclamar + + )} + + ) +} diff --git a/web-next/lib/recruit-claim.ts b/web-next/lib/recruit-claim.ts index ad404bf..6b3aad5 100644 --- a/web-next/lib/recruit-claim.ts +++ b/web-next/lib/recruit-claim.ts @@ -34,6 +34,21 @@ export async function getRecruitedLevel80Count(accountId: number): Promise { + let isRecruited = false + let recruitedCount = 0 + try { + const [r] = await db(DB.auth).query('SELECT recruiter FROM account WHERE id = ?', [accountId]) + isRecruited = Boolean(r[0]?.recruiter) + const [c] = await db(DB.auth).query('SELECT COUNT(*) AS n FROM account WHERE recruiter = ?', [accountId]) + recruitedCount = Number(c[0]?.n ?? 0) + } catch { + /* ignore */ + } + return { isRecruited, recruitedCount } +} + /** * Cuentas reclutadas válidas: tienen un personaje de nivel 80 y NO comparten IP * con el reclutador (anti-abuso). Devuelve hasta `requiredCount` IDs.