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