Files
NightSpire/web-next/components/AdminRecruitManager.tsx
T
Inna c6c56c3d95 Admin: recompensas de reclutamiento (recruit-a-friend)
- lib/admin-recruit.ts: list/create/delete sobre home_recruitreward
  (required_friends, reward_name, item_id, item_quantity, item_link, icon_class).
- API: POST /api/admin/recruit, DELETE /api/admin/recruit/[id] (403 sin admin).
- UI: /admin/recruit con AdminRecruitManager (alta ordenada por amigos requeridos),
  enlace desde el índice del admin.
- i18n es/en (Admin): 10 claves nuevas.

Verificado: build OK, /admin/recruit 307→login, APIs 403 sin admin,
columnas confirmadas en la migración 0001_initial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:41:31 +00:00

113 lines
4.7 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 type { RecruitReward } from '@/lib/admin-recruit'
const EMPTY = { requiredFriends: '', rewardName: '', itemId: '', itemQuantity: '1', itemLink: '', iconClass: '' }
export function AdminRecruitManager({ initial }: { initial: RecruitReward[] }) {
const t = useTranslations('Admin')
const [rewards, setRewards] = useState(initial)
const [form, setForm] = useState(EMPTY)
const [busy, setBusy] = useState(false)
function upd(k: string, v: string) {
setForm((f) => ({ ...f, [k]: v }))
}
async function create(e: React.FormEvent) {
e.preventDefault()
if (busy || !(Number(form.requiredFriends) > 0) || !form.rewardName.trim() || !(Number(form.itemId) > 0)) return
setBusy(true)
try {
const res = await fetch('/api/admin/recruit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(form),
})
const data: { success?: boolean; id?: number } = await res.json()
if (data.success && data.id) {
setRewards(
[
...rewards,
{
id: data.id,
required_friends: Number(form.requiredFriends),
reward_name: form.rewardName,
item_id: Number(form.itemId),
item_quantity: Number(form.itemQuantity) || 1,
item_link: form.itemLink,
icon_class: form.iconClass,
},
].sort((a, b) => a.required_friends - b.required_friends),
)
setForm(EMPTY)
}
} finally {
setBusy(false)
}
}
async function remove(id: number) {
if (!confirm(t('confirmDelete'))) return
const res = await fetch(`/api/admin/recruit/${id}`, { method: 'DELETE', credentials: 'same-origin' })
if ((await res.json()).success) setRewards(rewards.filter((r) => r.id !== id))
}
const field = 'nw-input'
return (
<div>
<form onSubmit={create} className="mb-8 grid gap-3 nw-card sm:grid-cols-2">
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('requiredFriends')}</span>
<input type="number" min="1" value={form.requiredFriends} onChange={(e) => upd('requiredFriends', e.target.value)} placeholder="1" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('rewardName')}</span>
<input value={form.rewardName} onChange={(e) => upd('rewardName', e.target.value)} placeholder="Montura épica" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('itemId')}</span>
<input type="number" min="1" value={form.itemId} onChange={(e) => upd('itemId', e.target.value)} placeholder="49284" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('itemQuantity')}</span>
<input type="number" min="1" value={form.itemQuantity} onChange={(e) => upd('itemQuantity', e.target.value)} className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('itemLink')}</span>
<input value={form.itemLink} onChange={(e) => upd('itemLink', e.target.value)} placeholder="https://…" className={field} />
</label>
<label className="text-sm">
<span className="mb-1 block text-nw-muted">{t('iconClass')}</span>
<input value={form.iconClass} onChange={(e) => upd('iconClass', e.target.value)} placeholder="icontinyl q3" className={field} />
</label>
<button type="submit" disabled={busy} className="nw-btn disabled:opacity-60 sm:col-span-2">
{busy ? t('creating') : t('create')}
</button>
</form>
{rewards.length === 0 ? (
<p className="text-amber-200/70">{t('noRewards')}</p>
) : (
<ul className="divide-y divide-amber-900/40">
{rewards.map((r) => (
<li key={r.id} className="flex items-center justify-between gap-4 py-3">
<div>
<p className="font-semibold text-amber-300">{r.reward_name}</p>
<p className="text-xs text-amber-200/50">
{t('requiredFriendsShort', { n: r.required_friends })} · item {r.item_id} ×{r.item_quantity}
</p>
</div>
<button onClick={() => remove(r.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
{t('delete')}
</button>
</li>
))}
</ul>
)}
</div>
)
}