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')}
|
{t('manageNews')}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/admin/prices" className="text-sky-400 hover:underline">
|
||||||
|
{t('managePrices')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</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.",
|
"noNews": "No news.",
|
||||||
"forbidden": "You do not have permission to access here.",
|
"forbidden": "You do not have permission to access here.",
|
||||||
"emptyError": "Fill in the title and content.",
|
"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.",
|
"noNews": "No hay noticias.",
|
||||||
"forbidden": "No tienes permiso para acceder aquí.",
|
"forbidden": "No tienes permiso para acceder aquí.",
|
||||||
"emptyError": "Completa el título y el contenido.",
|
"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