7534b9cbbb
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>
107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
'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>
|
||
)
|
||
}
|