web-next: /recruit según el markup (Información + Panel RAF con recompensas)
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
@@ -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 (
|
||||
<PageShell title={t('title')}>
|
||||
<div className="box-content">
|
||||
<div className="title-box-content">
|
||||
<h2>Información</h2>
|
||||
<Link className="back-to-account" href="/account">Regresar a mi cuenta</Link>
|
||||
<Link className="back-to-account-responsive" href="/account">Regresar</Link>
|
||||
</div>
|
||||
<div className="body-box-content justified">
|
||||
<p>{t('intro')}</p>
|
||||
<br />
|
||||
{session.accountId ? (
|
||||
<RecruitClaim
|
||||
<span>¿Qué es Recluta a un amigo?</span>
|
||||
<p>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.</p>
|
||||
<p>Sólo funciona para usuarios nuevos en el servidor y no tiene límites de hasta cuántas cuentas pueden reclutarse.</p>
|
||||
<p>Los beneficios funcionan para todos los personajes de cada cuenta y permanece para siempre.</p>
|
||||
<br />
|
||||
<p>- Quien se crea una cuenta completando el campo Personaje reclutante en el registro, es el referido/reclutante.</p>
|
||||
<p>- El personaje que se haya utilizado para crear la cuenta, es el referente/reclutante.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cómo reclutar a un amigo?</span>
|
||||
<p>Cuando tu amigo cree una cuenta, dile que en el campo 'Personaje reclutante' escriba el nombre de alguno de los personajes en tu cuenta.</p>
|
||||
<p>Una vez registrado como referido, ya podrán disfrutar de todos los beneficios.</p>
|
||||
<p>Asegúrate de elegir bien a tu referente y/o referido ya que la acción no es reversible.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cómo obtener los beneficios?</span>
|
||||
<p>- Ambos personajes tienen que estar leveando en grupo. Recuerda que deben estar ambos en lista de amigos para darse grupo.</p>
|
||||
<p>- Deben estar en una distancia relativamente cercana.</p>
|
||||
<p>- La diferencia máxima entre ambos jugadores debe ser de 4 niveles.</p>
|
||||
<br />
|
||||
<p>Si no se cumple alguna de las condiciones mencionadas, no se recibirá el bonus de experiencia.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cómo ver la experiencia obtenida?</span>
|
||||
<p>Podrás ver el bonus de experiencia recibida activando los mensajes de experiencia en el chat:</p>
|
||||
<p>1) Clic derecho en la pestaña de chat General.</p>
|
||||
<p>2) Clic izquierdo en Configuración.</p>
|
||||
<p>3) Clic izquierdo en Otros.</p>
|
||||
<p>4) Marca la casilla Experiencia.</p>
|
||||
<p>5) Clic izquierdo en Aceptar.</p>
|
||||
<br />
|
||||
<p>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.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Puedo desactivar el bonus de experiencia?</span>
|
||||
<p>El bonus será otorgado siempre y cuando se cumplan los requisitos.</p>
|
||||
<p>Si deseas dejar de obtenerlo, asegúrate de no cumplir alguna de las condiciones mencionadas en «¿Cómo obtener los beneficios?».</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Hay recompensas por reclutar amigos?</span>
|
||||
<p>Existen varias recompensas por reclutar amigos.</p>
|
||||
<p>En el recuadro debajo podrás ver cuáles son los objetivos de cada recompensa. (Debes estar conectado para ver el recuadro).</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cuáles son los beneficios?</span>
|
||||
<p>Cuando un personaje del referente y un personaje del referido levean juntos, serán beneficiados por:</p>
|
||||
<p>Hasta nivel 60:</p>
|
||||
<p>- La habilidad de 'Invocar amigo'.</p>
|
||||
<p>- El personaje referido podrá dar niveles al personaje referente.</p>
|
||||
<br />
|
||||
<p>Hasta nivel 80:</p>
|
||||
<p>- Recibirán experiencia x2.</p>
|
||||
<p>- Un bonus de reputación.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>Invocar amigo</span>
|
||||
<p>Esta habilidad aparece en 2 lugares:</p>
|
||||
<p>- Como opción al hacer click derecho en el avatar del personaje referente/referido.</p>
|
||||
<p>- Si tienes al personaje referente/referido en la lista de amigos, aparece un ícono especial a la derecha de su nombre.</p>
|
||||
<br />
|
||||
<p>Al usar esta habilidad se debe tener en target al personaje referente/referido y no debe ser modificado hasta finalizar su casteo.</p>
|
||||
<p>Su tiempo de reutilización es de 1 hora.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>Otorgar niveles</span>
|
||||
<p>Esta habilidad aparece al hacer click derecho en el avatar del personaje referente y puede utilizarla solamente el personaje referido.</p>
|
||||
<p>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.</p>
|
||||
<p>Para volver a otorgar un nivel, el personaje referido tendrá que subir 2 niveles más.</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Tienes dudas o problemas para reclutar amigos?</span>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div className="box-content">
|
||||
<div className="title-box-content"><h2>Panel de Recluta a un amigo</h2></div>
|
||||
<div className="body-box-content centered">
|
||||
{loggedIn ? (
|
||||
<RecruitPanel
|
||||
rewards={rewards}
|
||||
characters={(await getGameCharacters(session.accountId)).map((c) => c.name)}
|
||||
level80Count={await getRecruitedLevel80Count(session.accountId)}
|
||||
characters={characters}
|
||||
level80Count={level80Count}
|
||||
isRecruited={stats.isRecruited}
|
||||
recruitedCount={stats.recruitedCount}
|
||||
/>
|
||||
) : (
|
||||
<p className="centered">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<number | null>(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 (
|
||||
<div>
|
||||
<div className="info-box-light separate">
|
||||
<p className="second-brown">
|
||||
{t('level80Friends')}: <span className="yellow-info">{level80Count}</span>
|
||||
</p>
|
||||
{characters.length > 0 ? (
|
||||
<p>
|
||||
<span className="second-brown">{t('deliverTo')} </span>
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)}>
|
||||
{characters.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</p>
|
||||
) : (
|
||||
<p className="second-brown">{t('noCharacters')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="alert-message" style={{ display: msg ? 'block' : 'none' }}>
|
||||
{msg && <span className={msg.ok ? 'ok-form-response' : 'red-form-response'}>{msg.text}</span>}
|
||||
</div>
|
||||
|
||||
{rewards.length === 0 ? (
|
||||
<p className="second-brown">{t('noRewards')}</p>
|
||||
) : (
|
||||
<div className="centered">
|
||||
{rewards.map((r) => {
|
||||
const eligible = level80Count >= r.required_friends
|
||||
return (
|
||||
<div key={r.id} className="raf-box">
|
||||
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="yellow-info">
|
||||
{r.reward_name}
|
||||
</a>
|
||||
<p className="second-brown small-font">
|
||||
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
|
||||
</p>
|
||||
<br />
|
||||
{r.claimed ? (
|
||||
<span className="green-info">
|
||||
<i className="fas fa-check"></i> {t('claimed')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => claim(r)}
|
||||
disabled={!eligible || busyId !== null || characters.length === 0}
|
||||
title={!eligible ? t('notEnoughFriends') : undefined}
|
||||
>
|
||||
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<number | null>(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 (
|
||||
<>
|
||||
<a
|
||||
href={r.item_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`icontinyl ${quality || ''}`}
|
||||
style={{ paddingLeft: '18px', background: `url('${ICON_BASE}/${icon}.gif') left center no-repeat` }}
|
||||
>
|
||||
{r.reward_name}
|
||||
</a>
|
||||
{r.item_quantity > 1 ? ` x${r.item_quantity}` : ''}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="lefted">Cuenta reclutada: <span>{isRecruited ? 'Sí' : 'No'}</span></p>
|
||||
<p className="lefted">Amigos reclutados: <span>{recruitedCount}</span></p>
|
||||
<p className="lefted">Amigos reclutados al nivel 80: <span>{level80Count}</span></p>
|
||||
<p className="lefted">Recompensas reclamadas: <span>{claimedCount}/{rewards.length}</span></p>
|
||||
<br />
|
||||
|
||||
{characters.length > 0 && (
|
||||
<p className="lefted">
|
||||
Recibir recompensas en:{' '}
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} className="account-select">
|
||||
{characters.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</p>
|
||||
)}
|
||||
{msg && <p className={msg.ok ? 'green-info' : 'red-info'}>{msg.text}</p>}
|
||||
|
||||
<table className="char-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Amigos reclutados</th>
|
||||
<th>Recompensa</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
{rewards.map((r) => {
|
||||
const eligible = level80Count >= r.required_friends
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td><span>{r.required_friends} {r.required_friends === 1 ? 'amigo' : 'amigos'} al nivel 80</span></td>
|
||||
<td>{rewardCell(r)}</td>
|
||||
<td>
|
||||
{r.claimed ? (
|
||||
<span className="green-info"><i className="fas fa-check"></i> Reclamado</span>
|
||||
) : eligible ? (
|
||||
<button type="button" className="nw-tool-btn" onClick={() => claim(r)} disabled={busyId !== null}>
|
||||
{busyId === r.id ? '…' : 'Reclamar'}
|
||||
</button>
|
||||
) : (
|
||||
<span>Sin reclamar</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br />
|
||||
<hr />
|
||||
<p>RECOMPENSAS DISPONIBLES</p>
|
||||
<hr />
|
||||
{available.length === 0 ? (
|
||||
<span>Recluta amigos para poder reclamar las recompensas</span>
|
||||
) : (
|
||||
<span className="green-info">
|
||||
Tienes {available.length} recompensa{available.length === 1 ? '' : 's'} lista{available.length === 1 ? '' : 's'} para reclamar
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,21 @@ export async function getRecruitedLevel80Count(accountId: number): Promise<numbe
|
||||
return Number(rows[0]?.n ?? 0)
|
||||
}
|
||||
|
||||
/** Estado de reclutamiento de la cuenta: si fue reclutada y cuántos amigos ha reclutado. */
|
||||
export async function getRecruitStats(accountId: number): Promise<{ isRecruited: boolean; recruitedCount: number }> {
|
||||
let isRecruited = false
|
||||
let recruitedCount = 0
|
||||
try {
|
||||
const [r] = await db(DB.auth).query<RowDataPacket[]>('SELECT recruiter FROM account WHERE id = ?', [accountId])
|
||||
isRecruited = Boolean(r[0]?.recruiter)
|
||||
const [c] = await db(DB.auth).query<RowDataPacket[]>('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.
|
||||
|
||||
Reference in New Issue
Block a user