From 7534b9cbbb437643af0bc3f2ead05aca033e14e3 Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 00:45:29 +0000 Subject: [PATCH] =?UTF-8?q?Reclutamiento:=20p=C3=A1gina=20de=20usuario=20p?= =?UTF-8?q?ara=20reclamar=20recompensas=20(recruit-a-friend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web-next/app/[locale]/recruit/page.tsx | 38 +++++++ web-next/app/api/recruit/claim/route.ts | 19 ++++ web-next/components/Footer.tsx | 3 + web-next/components/RecruitClaim.tsx | 106 ++++++++++++++++++ web-next/lib/recruit-claim.ts | 137 ++++++++++++++++++++++++ web-next/messages/en.json | 27 ++++- web-next/messages/es.json | 27 ++++- 7 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 web-next/app/[locale]/recruit/page.tsx create mode 100644 web-next/app/api/recruit/claim/route.ts create mode 100644 web-next/components/RecruitClaim.tsx create mode 100644 web-next/lib/recruit-claim.ts diff --git a/web-next/app/[locale]/recruit/page.tsx b/web-next/app/[locale]/recruit/page.tsx new file mode 100644 index 0000000..bd5e0a8 --- /dev/null +++ b/web-next/app/[locale]/recruit/page.tsx @@ -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 ( +
+

{t('title')}

+

{t('intro')}

+ + {session.accountId ? ( + c.name)} + level80Count={await getRecruitedLevel80Count(session.accountId)} + /> + ) : ( +

+ + {t('loginToClaim')} + +

+ )} +
+ ) +} diff --git a/web-next/app/api/recruit/claim/route.ts b/web-next/app/api/recruit/claim/route.ts new file mode 100644 index 0000000..a755f03 --- /dev/null +++ b/web-next/app/api/recruit/claim/route.ts @@ -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 = {} + 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) +} diff --git a/web-next/components/Footer.tsx b/web-next/components/Footer.tsx index 43195d9..68fd1a1 100644 --- a/web-next/components/Footer.tsx +++ b/web-next/components/Footer.tsx @@ -45,6 +45,9 @@ export async function Footer() { {t('vote')} + + {t('recruit')} + {t('register')} diff --git a/web-next/components/RecruitClaim.tsx b/web-next/components/RecruitClaim.tsx new file mode 100644 index 0000000..7ba682a --- /dev/null +++ b/web-next/components/RecruitClaim.tsx @@ -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(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('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/lib/recruit-claim.ts b/web-next/lib/recruit-claim.ts new file mode 100644 index 0000000..ad404bf --- /dev/null +++ b/web-next/lib/recruit-claim.ts @@ -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( + '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 { + 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( + '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 { + 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('SELECT last_ip FROM account WHERE id = ?', [accountId]) + const userIp = ipRows[0]?.last_ip ?? null + + const [rows] = await db(DB.characters).query( + '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 { + const [rows] = await db(DB.default).query( + '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() + if (accountId) { + const [claimed] = await db(DB.default).query( + '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 { + const [rows] = await db(DB.characters).query( + '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 { + const [rewardRows] = await db(DB.default).query( + '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( + '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' } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index adfeac1..49cd613 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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}." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 3527041..557160c 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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}." } }