Reclutamiento: página de usuario para reclamar recompensas (recruit-a-friend)

Portado de home/views/recruit.py:
- lib/recruit-claim.ts: getRecruitRewards (con marca de reclamadas),
  getRecruitedLevel80Count, y claimRecruitReward con las mismas validaciones que
  Django (recompensa existe, no reclamada, amigos reclutados a nivel 80 suficientes,
  anti-abuso por IP compartida), entrega por SOAP `.send items` (éxito si "Mail sent to")
  y registro en home_claimedreward. Extra de seguridad: valida que el personaje sea de la cuenta.
- API POST /api/recruit/claim (401 sin sesión).
- UI /recruit (pública): rejilla de recompensas con estado reclamada/elegible/bloqueada,
  selector de personaje y contador de amigos a nivel 80. Enlace en el footer.
- i18n es/en: namespace RecruitClaim + Nav.recruit.

Verificado: build OK, /recruit 200, claim 401 sin sesión.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 00:45:29 +00:00
parent c6c56c3d95
commit 7534b9cbbb
7 changed files with 355 additions and 2 deletions
+38
View File
@@ -0,0 +1,38 @@
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 { RecruitClaim } from '@/components/RecruitClaim'
export const dynamic = 'force-dynamic'
export default async function RecruitPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('RecruitClaim')
const session = await getSession()
const rewards = await getRecruitRewards(session.accountId ?? null)
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="mb-2 text-2xl font-bold text-amber-500">{t('title')}</h1>
<p className="mb-6 text-nw-muted">{t('intro')}</p>
{session.accountId ? (
<RecruitClaim
rewards={rewards}
characters={(await getGameCharacters(session.accountId)).map((c) => c.name)}
level80Count={await getRecruitedLevel80Count(session.accountId)}
/>
) : (
<p className="text-amber-200/70">
<Link href="/login" className="text-sky-400 hover:underline">
{t('loginToClaim')}
</Link>
</p>
)}
</main>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { getSession } from '@/lib/session'
import { claimRecruitReward } from '@/lib/recruit-claim'
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId || !session.username) {
return Response.json({ success: false, message: 'notAuthenticated' }, { status: 401 })
}
let b: Record<string, unknown> = {}
try { b = await request.json() } catch { return Response.json({ success: false, message: 'invalidRequest' }, { status: 400 }) }
const rewardId = Number(b.rewardId)
const character = String(b.character ?? '').trim()
if (!rewardId || !character) return Response.json({ success: false, message: 'selectRewardAndCharacter' })
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '127.0.0.1'
const result = await claimRecruitReward(session.accountId, session.username, rewardId, character, ip)
return Response.json(result)
}
+3
View File
@@ -45,6 +45,9 @@ export async function Footer() {
<Link href="/vote-points" className="hover:text-nw-gold-light">
{t('vote')}
</Link>
<Link href="/recruit" className="hover:text-nw-gold-light">
{t('recruit')}
</Link>
<Link href="/register" className="hover:text-nw-gold-light">
{t('register')}
</Link>
+106
View File
@@ -0,0 +1,106 @@
'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="mb-6 flex flex-wrap items-center gap-3 nw-card">
<p className="text-sm text-nw-muted">
{t('level80Friends')}: <span className="font-semibold text-nw-gold-light">{level80Count}</span>
</p>
{characters.length > 0 ? (
<label className="ml-auto text-sm">
<span className="mr-2 text-nw-muted">{t('deliverTo')}</span>
<select value={character} onChange={(e) => setCharacter(e.target.value)} className="nw-input inline-block w-auto">
{characters.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
) : (
<p className="ml-auto text-sm text-amber-200/60">{t('noCharacters')}</p>
)}
</div>
{msg && <p className={`mb-4 ${msg.ok ? 'text-green-400' : 'text-red-400'}`}>{msg.text}</p>}
{rewards.length === 0 ? (
<p className="text-amber-200/70">{t('noRewards')}</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{rewards.map((r) => {
const eligible = level80Count >= r.required_friends
return (
<div key={r.id} className="flex flex-col justify-between nw-card">
<div>
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="font-semibold text-amber-300 hover:text-amber-400">
{r.reward_name}
</a>
<p className="mt-1 text-xs text-amber-200/60">
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
</div>
<div className="mt-3">
{r.claimed ? (
<span className="text-sm text-green-400"> {t('claimed')}</span>
) : (
<button
type="button"
onClick={() => claim(r)}
disabled={!eligible || busyId !== null || characters.length === 0}
className="nw-btn text-sm disabled:cursor-not-allowed disabled:opacity-50"
title={!eligible ? t('notEnoughFriends') : undefined}
>
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { executeSoapCommand } from './soap'
export interface RecruitRewardView {
id: number
required_friends: number
reward_name: string
item_id: number
item_quantity: number
item_link: string
icon_class: string
claimed: boolean
}
/** IDs de cuentas reclutadas por `accountId` (con su IP). */
async function getRecruitedAccounts(accountId: number): Promise<{ id: number; last_ip: string | null }[]> {
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT id, last_ip FROM account WHERE recruiter = ?',
[accountId],
)
return rows.map((r) => ({ id: r.id, last_ip: r.last_ip ?? null }))
}
/** Nº de personajes de amigos reclutados que han alcanzado el nivel 80. */
export async function getRecruitedLevel80Count(accountId: number): Promise<number> {
const recruited = await getRecruitedAccounts(accountId)
if (recruited.length === 0) return 0
const ids = recruited.map((r) => r.id)
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM characters WHERE level = 80 AND account IN (?)',
[ids],
)
return Number(rows[0]?.n ?? 0)
}
/**
* Cuentas reclutadas válidas: tienen un personaje de nivel 80 y NO comparten IP
* con el reclutador (anti-abuso). Devuelve hasta `requiredCount` IDs.
*/
async function getValidRecruitsAtLevel80(accountId: number, requiredCount: number): Promise<number[]> {
const recruited = await getRecruitedAccounts(accountId)
if (recruited.length === 0) return []
const ipById = new Map(recruited.map((r) => [r.id, r.last_ip]))
const ids = recruited.map((r) => r.id)
const [ipRows] = await db(DB.auth).query<RowDataPacket[]>('SELECT last_ip FROM account WHERE id = ?', [accountId])
const userIp = ipRows[0]?.last_ip ?? null
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT DISTINCT account FROM characters WHERE level = 80 AND account IN (?)',
[ids],
)
const atLevel80 = rows.map((r) => Number(r.account))
// Excluir los que comparten IP con el reclutador.
return atLevel80.filter((acc) => ipById.get(acc) !== userIp).slice(0, requiredCount)
}
/** Todas las recompensas con marca de si esta cuenta ya las reclamó. */
export async function getRecruitRewards(accountId: number | null): Promise<RecruitRewardView[]> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT id, required_friends, reward_name, item_id, item_quantity, item_link, icon_class FROM home_recruitreward ORDER BY required_friends, id',
)
let claimedIds = new Set<number>()
if (accountId) {
const [claimed] = await db(DB.default).query<RowDataPacket[]>(
'SELECT recruit_reward_id FROM home_claimedreward WHERE account_id = ?',
[accountId],
)
claimedIds = new Set(claimed.map((c) => Number(c.recruit_reward_id)))
}
return rows.map((r) => ({
id: r.id,
required_friends: r.required_friends,
reward_name: r.reward_name,
item_id: r.item_id,
item_quantity: r.item_quantity,
item_link: r.item_link,
icon_class: r.icon_class,
claimed: claimedIds.has(Number(r.id)),
}))
}
/** ¿El personaje pertenece a la cuenta? */
async function characterBelongsToAccount(accountId: number, name: string): Promise<boolean> {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT 1 FROM characters WHERE name = ? AND account = ? LIMIT 1',
[name, accountId],
)
return Boolean(rows[0])
}
export interface ClaimResult {
success: boolean
message: string // clave i18n de RecruitClaim
}
/** Reclama una recompensa de reclutamiento: valida requisitos, envía el item por SOAP y lo registra. */
export async function claimRecruitReward(
accountId: number,
username: string,
rewardId: number,
characterName: string,
ip: string,
): Promise<ClaimResult> {
const [rewardRows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT id, required_friends, reward_name, item_id, item_quantity FROM home_recruitreward WHERE id = ?',
[rewardId],
)
const reward = rewardRows[0]
if (!reward) return { success: false, message: 'rewardNotFound' }
if (!(await characterBelongsToAccount(accountId, characterName))) {
return { success: false, message: 'invalidCharacter' }
}
const [already] = await db(DB.default).query<RowDataPacket[]>(
'SELECT 1 FROM home_claimedreward WHERE account_id = ? AND recruit_reward_id = ? LIMIT 1',
[accountId, rewardId],
)
if (already[0]) return { success: false, message: 'alreadyClaimed' }
const valid = await getValidRecruitsAtLevel80(accountId, reward.required_friends)
if (valid.length < reward.required_friends) return { success: false, message: 'requirementsNotMet' }
const command = `.send items ${characterName} 'Recompensa de Recluta Amigo' 'Tu amigo alcanzó el Nivel 80' ${reward.item_id}:${reward.item_quantity}`
const response = await executeSoapCommand(command)
if (!response || !response.includes('Mail sent to')) {
return { success: false, message: 'deliveryError' }
}
await db(DB.default).query(
'INSERT INTO home_claimedreward (account_id, username, recruit_reward_id, character_name, claimed_at, ip_address) VALUES (?, ?, ?, ?, NOW(), ?)',
[accountId, username, rewardId, characterName, ip.slice(0, 45)],
)
return { success: true, message: 'claimSuccess' }
}
+26 -1
View File
@@ -11,7 +11,8 @@
"logout": "Log out",
"language": "Language",
"forum": "Forums",
"vote": "Vote"
"vote": "Vote",
"recruit": "Recruit"
},
"Home": {
"brand": "Nova WoW",
@@ -374,5 +375,29 @@
"prev": "Previous",
"next": "Next",
"page": "Page"
},
"RecruitClaim": {
"title": "Recruit a friend",
"intro": "Invite your friends: when they reach level 80, claim rewards.",
"loginToClaim": "Log in to claim rewards",
"level80Friends": "Recruited friends at level 80",
"deliverTo": "Deliver to",
"noCharacters": "You have no characters.",
"noRewards": "No rewards available.",
"requires": "Requires {n} friend(s)",
"claimed": "Claimed",
"claiming": "Claiming…",
"claim": "Claim",
"locked": "Locked",
"notEnoughFriends": "You dont have enough level-80 friends.",
"notAuthenticated": "You must be logged in.",
"invalidRequest": "Invalid request.",
"selectRewardAndCharacter": "Select a reward and a character.",
"rewardNotFound": "Reward not found.",
"invalidCharacter": "That character is not yours.",
"alreadyClaimed": "You already claimed this reward.",
"requirementsNotMet": "Requirements not met (or friends share your IP).",
"deliveryError": "Error delivering the reward.",
"claimSuccess": "Reward “{reward}” delivered to {character}."
}
}
+26 -1
View File
@@ -11,7 +11,8 @@
"logout": "Cerrar sesión",
"language": "Idioma",
"forum": "Foros",
"vote": "Votar"
"vote": "Votar",
"recruit": "Reclutar"
},
"Home": {
"brand": "Nova WoW",
@@ -374,5 +375,29 @@
"prev": "Anterior",
"next": "Siguiente",
"page": "Página"
},
"RecruitClaim": {
"title": "Recluta a un amigo",
"intro": "Invita a tus amigos: cuando alcancen el nivel 80, reclama recompensas.",
"loginToClaim": "Inicia sesión para reclamar recompensas",
"level80Friends": "Amigos reclutados a nivel 80",
"deliverTo": "Entregar a",
"noCharacters": "No tienes personajes.",
"noRewards": "No hay recompensas disponibles.",
"requires": "Requiere {n} amigo(s)",
"claimed": "Reclamada",
"claiming": "Reclamando…",
"claim": "Reclamar",
"locked": "Bloqueada",
"notEnoughFriends": "No tienes suficientes amigos a nivel 80.",
"notAuthenticated": "Debes iniciar sesión.",
"invalidRequest": "Solicitud no válida.",
"selectRewardAndCharacter": "Selecciona una recompensa y un personaje.",
"rewardNotFound": "Recompensa no encontrada.",
"invalidCharacter": "Ese personaje no es tuyo.",
"alreadyClaimed": "Ya has reclamado esta recompensa.",
"requirementsNotMet": "No cumples los requisitos (o los amigos comparten tu IP).",
"deliveryError": "Error al entregar la recompensa.",
"claimSuccess": "Recompensa «{reward}» entregada a {character}."
}
}