Admin: gestión de precios de los servicios de personaje

- lib/admin-prices.ts: PRICE_TABLES (rename/customize/change-race/change-faction/
  level-up/transfer -> home_*price), getAllPrices, setPrice (fila única: DELETE+INSERT).
- Route /api/admin/prices (POST, guard isAdmin). Página /admin/prices con
  AdminPricesManager (editar cada precio). Enlace en el dashboard admin.
- Catálogo Admin (precios + nombres de servicio).

Verificado: /admin/prices redirige sin permiso, API 403; setPrice escribe en BD
correctamente (probado con env cargado). Pendiente admin: sitios de voto, gold options,
moderación del foro.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:59:45 +00:00
parent d5b00a86dc
commit 0f71e1f4d9
7 changed files with 171 additions and 2 deletions
+5
View File
@@ -22,6 +22,11 @@ export default async function AdminPage({ params }: { params: Promise<{ locale:
{t('manageNews')}
</Link>
</li>
<li>
<Link href="/admin/prices" className="text-sky-400 hover:underline">
{t('managePrices')}
</Link>
</li>
</ul>
</main>
)
@@ -0,0 +1,24 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin'
import { getAllPrices } from '@/lib/admin-prices'
import { AdminPricesManager } from '@/components/AdminPricesManager'
export const dynamic = 'force-dynamic'
export default async function AdminPricesPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Admin')
const session = await getSession()
if (!session.bnetId) redirect({ href: '/login', locale })
if (!(await isAdmin(session))) redirect({ href: '/', locale })
const prices = await getAllPrices()
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('prices')}</h1>
<AdminPricesManager prices={prices} />
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin'
import { setPrice } from '@/lib/admin-prices'
export async function POST(request: Request) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, unknown> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const service = String(b.service ?? '')
const value = Number(b.value)
if (!service || !(value >= 0)) return Response.json({ success: false, error: 'invalidRequest' })
const ok = await setPrice(service, value)
return Response.json({ success: ok })
}
@@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import type { PriceRow } from '@/lib/admin-prices'
function Row({ row }: { row: PriceRow }) {
const t = useTranslations('Admin')
const [value, setValue] = useState(String(row.price))
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState(false)
async function save() {
setBusy(true)
setSaved(false)
try {
const res = await fetch('/api/admin/prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ service: row.service, value: Number(value) }),
})
const data: { success?: boolean } = await res.json()
if (data.success) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} finally {
setBusy(false)
}
}
return (
<li className="flex items-center justify-between gap-4 py-3">
<span className="text-amber-300">{t(`svc_${row.service}`)}</span>
<div className="flex items-center gap-2">
<input
type="number"
step="0.01"
min="0"
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-24 rounded border border-amber-900/60 bg-[#2c1e14] px-2 py-1 text-right"
/>
<button onClick={save} disabled={busy} className="rounded bg-amber-600 px-3 py-1 text-sm font-semibold text-[#1b120b] disabled:opacity-60">
{t('save')}
</button>
{saved && <span className="text-sm text-green-400">{t('saved')}</span>}
</div>
</li>
)
}
export function AdminPricesManager({ prices }: { prices: PriceRow[] }) {
return (
<ul className="divide-y divide-amber-900/40">
{prices.map((row) => (
<Row key={row.service} row={row} />
))}
</ul>
)
}
+41
View File
@@ -0,0 +1,41 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
// Servicios con precio único (una fila) -> tabla django_wow.
export const PRICE_TABLES: Record<string, string> = {
rename: 'home_renameprice',
customize: 'home_customizeprice',
'change-race': 'home_changeraceprice',
'change-faction': 'home_changefactionprice',
'level-up': 'home_levelupprice',
transfer: 'home_transferprice',
}
export interface PriceRow {
service: string
price: number
}
export async function getAllPrices(): Promise<PriceRow[]> {
const out: PriceRow[] = []
for (const [service, table] of Object.entries(PRICE_TABLES)) {
let price = 0
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(`SELECT price FROM ${table} LIMIT 1`)
price = rows[0] ? Number(rows[0].price) : 0
} catch {
/* ignore */
}
out.push({ service, price })
}
return out
}
/** Fija el precio (fila única) de un servicio. */
export async function setPrice(service: string, value: number): Promise<boolean> {
const table = PRICE_TABLES[service]
if (!table || !(value >= 0)) return false
await db(DB.default).query(`DELETE FROM ${table}`)
await db(DB.default).query(`INSERT INTO ${table} (price) VALUES (?)`, [value])
return true
}
+12 -1
View File
@@ -281,6 +281,17 @@
"noNews": "No news.",
"forbidden": "You do not have permission to access here.",
"emptyError": "Fill in the title and content.",
"genericError": "Something went wrong."
"genericError": "Something went wrong.",
"managePrices": "Manage prices",
"prices": "Service prices",
"price": "Price (€)",
"save": "Save",
"saved": "Saved",
"svc_rename": "Rename character",
"svc_customize": "Customize",
"svc_change-race": "Change race",
"svc_change-faction": "Change faction",
"svc_level-up": "Level up to 80",
"svc_transfer": "Transfer character"
}
}
+12 -1
View File
@@ -281,6 +281,17 @@
"noNews": "No hay noticias.",
"forbidden": "No tienes permiso para acceder aquí.",
"emptyError": "Completa el título y el contenido.",
"genericError": "Algo ha salido mal."
"genericError": "Algo ha salido mal.",
"managePrices": "Gestionar precios",
"prices": "Precios de servicios",
"price": "Precio (€)",
"save": "Guardar",
"saved": "Guardado",
"svc_rename": "Renombrar personaje",
"svc_customize": "Personalizar",
"svc_change-race": "Cambiar raza",
"svc_change-faction": "Cambiar facción",
"svc_level-up": "Subir a nivel 80",
"svc_transfer": "Transferir personaje"
}
}