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)
}