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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 don’t 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}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user