web-next: /recruit según el markup (Información + Panel RAF con recompensas)

- Reescrita la página: box «Información» con la guía completa de Recluta a un
  amigo (marca → nombre del reino) + box «Panel de Recluta a un amigo».
- Nuevo RecruitPanel: estadísticas (cuenta reclutada, amigos reclutados, amigos
  al nivel 80, recompensas reclamadas X/6) + tabla de recompensas (objetivo,
  objeto con icono y color de calidad, estado) con botón Reclamar para las
  disponibles + sección «Recompensas disponibles». Sustituye a RecruitClaim.
- lib/recruit-claim: getRecruitStats (isRecruited + recruitedCount).
- Colores de calidad de objeto .q0-.q5 en globals.css.
- Sembradas las 6 recompensas en home_recruitreward (seed en sql/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 16:14:10 +00:00
parent 02859bab1f
commit c3e57ff25e
6 changed files with 294 additions and 114 deletions
-106
View File
@@ -1,106 +0,0 @@
'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="info-box-light separate">
<p className="second-brown">
{t('level80Friends')}: <span className="yellow-info">{level80Count}</span>
</p>
{characters.length > 0 ? (
<p>
<span className="second-brown">{t('deliverTo')} </span>
<select value={character} onChange={(e) => setCharacter(e.target.value)}>
{characters.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</p>
) : (
<p className="second-brown">{t('noCharacters')}</p>
)}
</div>
<div className="alert-message" style={{ display: msg ? 'block' : 'none' }}>
{msg && <span className={msg.ok ? 'ok-form-response' : 'red-form-response'}>{msg.text}</span>}
</div>
{rewards.length === 0 ? (
<p className="second-brown">{t('noRewards')}</p>
) : (
<div className="centered">
{rewards.map((r) => {
const eligible = level80Count >= r.required_friends
return (
<div key={r.id} className="raf-box">
<a href={r.item_link} target="_blank" rel="noopener noreferrer" className="yellow-info">
{r.reward_name}
</a>
<p className="second-brown small-font">
{t('requires', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
<br />
{r.claimed ? (
<span className="green-info">
<i className="fas fa-check"></i> {t('claimed')}
</span>
) : (
<button
type="button"
onClick={() => claim(r)}
disabled={!eligible || busyId !== null || characters.length === 0}
title={!eligible ? t('notEnoughFriends') : undefined}
>
{busyId === r.id ? t('claiming') : eligible ? t('claim') : t('locked')}
</button>
)}
</div>
)
})}
</div>
)}
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import type { RecruitRewardView } from '@/lib/recruit-claim'
const ICON_BASE = 'https://wotlk.ultimowow.com/static/images/wow/icons/tiny'
function claimError(message?: string): string {
switch (message) {
case 'requirementsNotMet':
return 'Aún no cumples los requisitos para esta recompensa.'
case 'alreadyClaimed':
return 'Esta recompensa ya fue reclamada.'
case 'invalidCharacter':
return 'Personaje no válido.'
case 'notFound':
return 'Recompensa no encontrada.'
default:
return 'No se pudo reclamar la recompensa. Inténtalo de nuevo.'
}
}
/**
* Panel de Recluta a un amigo: estadísticas de la cuenta + tabla de recompensas
* (objetivo, objeto y estado) con botón para reclamar las disponibles.
*/
export function RecruitPanel({
rewards,
characters,
level80Count,
isRecruited,
recruitedCount,
}: {
rewards: RecruitRewardView[]
characters: string[]
level80Count: number
isRecruited: boolean
recruitedCount: number
}) {
const [character, setCharacter] = useState(characters[0] ?? '')
const [busyId, setBusyId] = useState<number | null>(null)
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
const claimedCount = rewards.filter((r) => r.claimed).length
const available = rewards.filter((r) => !r.claimed && level80Count >= r.required_friends)
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()
if (d.success) {
window.location.reload()
return
}
setMsg({ ok: false, text: claimError(d.message) })
setBusyId(null)
} catch {
setMsg({ ok: false, text: claimError() })
setBusyId(null)
}
}
function rewardCell(r: RecruitRewardView) {
const [quality, icon] = (r.icon_class || '').split(' ')
return (
<>
<a
href={r.item_link}
target="_blank"
rel="noopener noreferrer"
className={`icontinyl ${quality || ''}`}
style={{ paddingLeft: '18px', background: `url('${ICON_BASE}/${icon}.gif') left center no-repeat` }}
>
{r.reward_name}
</a>
{r.item_quantity > 1 ? ` x${r.item_quantity}` : ''}
</>
)
}
return (
<>
<p className="lefted">Cuenta reclutada: <span>{isRecruited ? 'Sí' : 'No'}</span></p>
<p className="lefted">Amigos reclutados: <span>{recruitedCount}</span></p>
<p className="lefted">Amigos reclutados al nivel 80: <span>{level80Count}</span></p>
<p className="lefted">Recompensas reclamadas: <span>{claimedCount}/{rewards.length}</span></p>
<br />
{characters.length > 0 && (
<p className="lefted">
Recibir recompensas en:{' '}
<select value={character} onChange={(e) => setCharacter(e.target.value)} className="account-select">
{characters.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</p>
)}
{msg && <p className={msg.ok ? 'green-info' : 'red-info'}>{msg.text}</p>}
<table className="char-center-table">
<tbody>
<tr>
<th>Amigos reclutados</th>
<th>Recompensa</th>
<th>Estado</th>
</tr>
{rewards.map((r) => {
const eligible = level80Count >= r.required_friends
return (
<tr key={r.id}>
<td><span>{r.required_friends} {r.required_friends === 1 ? 'amigo' : 'amigos'} al nivel 80</span></td>
<td>{rewardCell(r)}</td>
<td>
{r.claimed ? (
<span className="green-info"><i className="fas fa-check"></i> Reclamado</span>
) : eligible ? (
<button type="button" className="nw-tool-btn" onClick={() => claim(r)} disabled={busyId !== null}>
{busyId === r.id ? '…' : 'Reclamar'}
</button>
) : (
<span>Sin reclamar</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<br />
<hr />
<p>RECOMPENSAS DISPONIBLES</p>
<hr />
{available.length === 0 ? (
<span>Recluta amigos para poder reclamar las recompensas</span>
) : (
<span className="green-info">
Tienes {available.length} recompensa{available.length === 1 ? '' : 's'} lista{available.length === 1 ? '' : 's'} para reclamar
</span>
)}
</>
)
}