Files
NightSpire/web-next/components/RecruitClaim.tsx
T
Inna 7534b9cbbb 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>
2026-07-13 00:45:29 +00:00

107 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}