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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user