From 1a2d40ee9c9dad6bfab17ac4b79b3516953c5504 Mon Sep 17 00:00:00 2001 From: adevopg Date: Tue, 14 Jul 2026 13:49:56 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20/send-gift=20(Enviar=20regalo)=20?= =?UTF-8?q?pagado=20por=20SumUp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Envía ítems de la tienda por correo al personaje de un amigo, pagado por SumUp. lib/gift.ts (catálogo por categoría, priceCart con precios reales de BD, sendGiftByMail vía SOAP .send items troceado a 12/correo + anti-inyección); servicio send-gift en PAID_SERVICES; ruta dedicada /api/gift/checkout (origen propio, destino existe, token de seguridad, carrito); SendGiftForm + página con el diseño; i18n Paid[send-gift]; seed_giftitems.sql (4 cat, 8 ítems). Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/send-gift/page.tsx | 72 +++++++ web-next/app/api/gift/checkout/route.ts | 87 ++++++++ web-next/components/SendGiftForm.tsx | 247 +++++++++++++++++++++++ web-next/lib/gift.ts | 138 +++++++++++++ web-next/lib/paid-services.ts | 18 ++ web-next/messages/en.json | 8 +- web-next/messages/es.json | 8 +- web-next/sql/seed_giftitems.sql | 20 ++ 8 files changed, 594 insertions(+), 4 deletions(-) create mode 100644 web-next/app/[locale]/send-gift/page.tsx create mode 100644 web-next/app/api/gift/checkout/route.ts create mode 100644 web-next/components/SendGiftForm.tsx create mode 100644 web-next/lib/gift.ts create mode 100644 web-next/sql/seed_giftitems.sql diff --git a/web-next/app/[locale]/send-gift/page.tsx b/web-next/app/[locale]/send-gift/page.tsx new file mode 100644 index 0000000..b5cdf4c --- /dev/null +++ b/web-next/app/[locale]/send-gift/page.tsx @@ -0,0 +1,72 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect, Link } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getGameCharacters } from '@/lib/characters' +import { getGiftCatalog } from '@/lib/gift' +import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' +import { SendGiftForm } from '@/components/SendGiftForm' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Enviar regalo' } + +export default async function SendGiftPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const [chars, catalog] = await Promise.all([getGameCharacters(session.accountId!), getGiftCatalog()]) + const currency = process.env.SUMUP_CURRENCY === 'EUR' ? '€' : process.env.SUMUP_CURRENCY || '€' + + return ( + + +
+

+ La herramienta Enviar regalo te permite enviar regalos a tus amigos. +

+
+

+ Al elegir desde qué personaje se enviará el regalo y el personaje que lo recibirá, podrás ver la lista de + objetos disponibles para enviar como regalo. +

+

Los objetos disponibles son los mismos de la tienda y puedes enviar varios objetos a la vez.

+
+

+ Al usar el botón ENVIAR REGALO se enviará un correo al personaje destino con los ítems seleccionados, + indicando quién lo ha regalado. El pago se realiza mediante SumUp. +

+
+

+ Si aún no tienes Token de seguridad, puedes solicitarlo{' '} + + aquí + + . +

+
+
+ NOTA +
+

+ Por favor, asegúrate de haber escrito el nombre del personaje correcto ya que esta acción no es + reversible una vez realizada. +

+
+
+
+ + ({ name: c.name, classCss: c.classCss }))} + catalog={catalog} + currency={currency} + /> +
+
+ ) +} diff --git a/web-next/app/api/gift/checkout/route.ts b/web-next/app/api/gift/checkout/route.ts new file mode 100644 index 0000000..001a675 --- /dev/null +++ b/web-next/app/api/gift/checkout/route.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'crypto' +import { getSession } from '@/lib/session' +import { getGameCharacters, getAccountIdByCharacterName } from '@/lib/characters' +import { checkSecurityToken } from '@/lib/security-token' +import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup' +import { priceCart, isValidCharName, type CartLine } from '@/lib/gift' + +/** + * Inicia el pago (SumUp) de un regalo. Condiciones: + * - Sesión con cuenta de juego seleccionada. + * - Personaje de ORIGEN: uno de tu cuenta. + * - Personaje de DESTINO: un nombre válido que exista (el de un amigo). + * - Token de seguridad válido. + * - Carrito no vacío; el total se calcula con los precios REALES de la BD. + * Al pagar, la reconciliación/return envía los ítems por correo (`.send items`). + */ +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + + let body: { source?: string; destination?: string; cart?: CartLine[]; security_token?: string; provider?: string } = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + + const source = String(body.source ?? '') + const destination = String(body.destination ?? '').trim() + const token = String(body.security_token ?? '').trim() + const cart = Array.isArray(body.cart) ? body.cart : [] + + if (!source || !destination || !token) { + return Response.json({ success: false, message: 'Completa el personaje de origen, el de destino y el token de seguridad.' }) + } + if (!isValidCharName(destination)) { + return Response.json({ success: false, message: 'El nombre del personaje de destino no es válido.' }) + } + + // Origen: debe pertenecer a tu cuenta. + const chars = await getGameCharacters(session.accountId) + if (!chars.find((c) => c.name === source)) { + return Response.json({ success: false, message: 'El personaje de origen no pertenece a tu cuenta.' }) + } + + // Destino: debe existir. + const destAccount = await getAccountIdByCharacterName(destination) + if (!destAccount) return Response.json({ success: false, message: 'El personaje de destino no existe.' }) + + // Token de seguridad. + if (!(await checkSecurityToken(session.accountId, token))) { + return Response.json({ success: false, message: 'El token de seguridad no es válido.' }) + } + + // Carrito -> precio real (BD). Nunca se confía en el precio del cliente. + const priced = await priceCart(cart) + if (!priced || priced.total <= 0) { + return Response.json({ success: false, message: 'Tu carrito está vacío o contiene objetos no válidos.' }) + } + + if (String(body.provider) !== 'sumup' || !sumupConfigured()) { + return Response.json({ success: false, error: 'notConfigured', message: 'El método de pago no está disponible ahora mismo.' }) + } + + const site = process.env.SITE_URL || '' + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' + const reference = randomUUID() + const items = priced.lines.map((l) => ({ i: l.itemId, q: l.qty })) + const description = `Regalo para ${destination} (${priced.lines.length} objeto${priced.lines.length === 1 ? '' : 's'})` + + const r = await createSumUpCheckout({ + accountId: session.accountId, + username: session.username || '', + email: session.bnetEmail || '', + acoreIp: ip, + amount: priced.total, + points: 0, + reference, + description, + returnUrl: `${site}/service-success?service=send-gift&provider=sumup&ref=${reference}`, + service: 'send-gift', + characterName: destination, + metadata: { sender: source, items: JSON.stringify(items), amount: String(priced.total) }, + }) + if (!r.success || !r.url) return Response.json({ success: false, error: r.error || 'sumupError', message: 'No se pudo iniciar el pago.' }) + return Response.json({ success: true, url: r.url }) +} diff --git a/web-next/components/SendGiftForm.tsx b/web-next/components/SendGiftForm.tsx new file mode 100644 index 0000000..16f6ab4 --- /dev/null +++ b/web-next/components/SendGiftForm.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useMemo, useState } from 'react' +import type { GiftCategory, GiftItem } from '@/lib/gift' +import { CharacterSelect, type CharOption } from '@/components/CharacterSelect' + +// Tope de cantidad en la interfaz. El servidor lo revalida en priceCart (fuente de verdad). +const MAX_QTY = 100 + +interface CartEntry { + item: GiftItem + qty: number +} + +/** Campo con ojo para mostrar/ocultar el token de seguridad. */ +function SecretInput({ + value, + onChange, + placeholder, +}: { + value: string + onChange: (v: string) => void + placeholder: string +}) { + const [show, setShow] = useState(false) + return ( + <> + onChange(e.target.value)} + placeholder={placeholder} + required + />{' '} + setShow((s) => !s)} + /> + + ) +} + +export function SendGiftForm({ + characters, + catalog, + currency = '€', +}: { + characters: CharOption[] + catalog: GiftCategory[] + currency?: string +}) { + const [source, setSource] = useState('') + const [destination, setDestination] = useState('') + const [token, setToken] = useState('') + const [cart, setCart] = useState>(new Map()) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const total = useMemo( + () => Math.round([...cart.values()].reduce((s, e) => s + e.item.price * e.qty, 0) * 100) / 100, + [cart], + ) + + function addItem(item: GiftItem) { + setCart((prev) => { + const next = new Map(prev) + const cur = next.get(item.id) + next.set(item.id, { item, qty: Math.min(MAX_QTY, (cur?.qty ?? 0) + 1) }) + return next + }) + } + function setQty(id: number, qty: number) { + setCart((prev) => { + const next = new Map(prev) + const cur = next.get(id) + if (!cur) return prev + const q = Math.max(1, Math.min(MAX_QTY, Math.floor(qty) || 1)) + next.set(id, { ...cur, qty: q }) + return next + }) + } + function removeItem(id: number) { + setCart((prev) => { + const next = new Map(prev) + next.delete(id) + return next + }) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy) return + if (!source || !destination.trim() || !token.trim()) { + setError('Completa el personaje de origen, el de destino y el token de seguridad.') + return + } + if (cart.size === 0) { + setError('Añade al menos un objeto al carrito.') + return + } + if ( + !window.confirm( + `¿Enviar el regalo a "${destination.trim()}" por ${total} ${currency} (SumUp)? Esta acción no es reversible.`, + ) + ) + return + setBusy(true) + setError(null) + try { + const res = await fetch('/api/gift/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + source, + destination: destination.trim(), + security_token: token.trim(), + cart: [...cart.values()].map((e) => ({ id: e.item.id, qty: e.qty })), + provider: 'sumup', + }), + }) + const data: { success?: boolean; url?: string; message?: string } = await res.json() + if (data.success && data.url) window.location.href = data.url + else { + setError(data.message || 'No se pudo iniciar el pago. Inténtalo de nuevo.') + setBusy(false) + } + } catch { + setError('Algo ha salido mal. Inténtalo más tarde.') + setBusy(false) + } + } + + if (characters.length === 0) return

No tienes personajes disponibles.

+ if (catalog.length === 0) return

No hay objetos disponibles para regalar por ahora.

+ + return ( +
+
+

Escoge desde qué personaje se envía el regalo:

+ +
+

Nombre del personaje que recibirá el regalo:

+ setDestination(e.target.value)} + required + /> +
+

Token de seguridad:

+ +
+ + {/* Catálogo de objetos */} +
+ {catalog.map((cat) => ( +
+

{cat.name}

+
+ {cat.items.map((it) => ( +
+ + {it.name} + +
{it.name}
+
+ {it.price} {currency} +
+ +
+ ))} +
+
+ ))} +
+
+ + {/* Carrito */} +

Carrito

+ {cart.size === 0 ? ( +

Aún no has añadido objetos.

+ ) : ( + + + {[...cart.values()].map((e) => ( + + + + + + + + ))} + +
+ {e.item.name} + {e.item.name} + setQty(e.item.id, Number(ev.target.value))} + style={{ width: '4em' }} + /> + {Math.round(e.item.price * e.qty * 100) / 100} {currency} + +
+ )} + +
+
+

+ Total: {total} {currency} (SumUp) +

+
+ +
+ +
+
+ {error && {error}} +
+
+ ) +} diff --git a/web-next/lib/gift.ts b/web-next/lib/gift.ts new file mode 100644 index 0000000..c8661f4 --- /dev/null +++ b/web-next/lib/gift.ts @@ -0,0 +1,138 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { executeSoapCommand } from './soap' + +export interface GiftItem { + id: number // PK en home_item (identificador del carrito) + itemId: number // id del ítem en el juego + name: string + price: number // precio en euros + imageUrl: string +} + +export interface GiftCategory { + id: number + name: string + items: GiftItem[] +} + +/** Nombre de personaje válido de WoW (letras, 1-12). Evita inyección en el SOAP. */ +const SAFE_NAME = /^[A-Za-z]{1,12}$/ +/** Cantidad máxima por línea del carrito. */ +export const MAX_QTY = 100 +/** AzerothCore entrega como máximo 12 ítems por correo. */ +const MAIL_ITEM_LIMIT = 12 + +export function isValidCharName(name: string): boolean { + return SAFE_NAME.test(name) +} + +/** Catálogo de regalos (= ítems de la tienda), agrupado por categoría. */ +export async function getGiftCatalog(): Promise { + try { + const [rows] = await db(DB.default).query( + `SELECT c.id AS cat_id, c.name AS cat_name, i.id, i.item_id, i.name, i.price, i.image_url + FROM home_category c + JOIN home_item i ON i.category_id = c.id + ORDER BY c.name, i.name`, + ) + const byCat = new Map() + for (const r of rows) { + let cat = byCat.get(r.cat_id) + if (!cat) { + cat = { id: r.cat_id, name: r.cat_name, items: [] } + byCat.set(r.cat_id, cat) + } + cat.items.push({ id: r.id, itemId: r.item_id, name: r.name, price: Number(r.price), imageUrl: r.image_url }) + } + return [...byCat.values()] + } catch { + return [] + } +} + +export interface CartLine { + id: number + qty: number +} +export interface PricedLine { + itemId: number + name: string + qty: number + unit: number + subtotal: number +} +export interface PricedCart { + lines: PricedLine[] + total: number +} + +/** + * Valida un carrito contra los PRECIOS REALES de la BD y calcula el total. + * Nunca se confía en el precio que envía el cliente. Devuelve null si el + * carrito está vacío, tiene cantidades no válidas o algún ítem no existe. + */ +export async function priceCart(cart: CartLine[]): Promise { + const qtyById = new Map() + for (const l of cart) { + const id = Math.floor(Number(l?.id)) + const qty = Math.floor(Number(l?.qty)) + if (!Number.isInteger(id) || id <= 0) return null + if (!Number.isInteger(qty) || qty < 1 || qty > MAX_QTY) return null + qtyById.set(id, Math.min(MAX_QTY, (qtyById.get(id) ?? 0) + qty)) + } + if (qtyById.size === 0) return null + + const ids = [...qtyById.keys()] + let rows: RowDataPacket[] + try { + ;[rows] = await db(DB.default).query( + `SELECT id, item_id, name, price FROM home_item WHERE id IN (${ids.map(() => '?').join(',')})`, + ids, + ) + } catch { + return null + } + if (rows.length !== ids.length) return null // algún id no existe en el catálogo + + const lines: PricedLine[] = [] + let total = 0 + for (const r of rows) { + const qty = qtyById.get(Number(r.id))! + const unit = Number(r.price) + const subtotal = Math.round(unit * qty * 100) / 100 + total += subtotal + lines.push({ itemId: Number(r.item_id), name: r.name, qty, unit, subtotal }) + } + return { lines, total: Math.round(total * 100) / 100 } +} + +/** + * Entrega el regalo: envía los ítems por correo al personaje destino con SOAP + * (`.send items`, igual que la tienda). Trocea en correos de máximo 12 ítems + * (límite de AzerothCore). `items` = pares { i: item_id, q: cantidad }. + * Requiere el worldserver arriba; devuelve false si algún correo falla. + */ +export async function sendGiftByMail( + destination: string, + sender: string, + items: { i: number; q: number }[], +): Promise { + if (!SAFE_NAME.test(destination)) return false + const clean = (Array.isArray(items) ? items : []) + .map((it) => ({ i: Math.floor(Number(it?.i)), q: Math.floor(Number(it?.q)) })) + .filter((it) => Number.isInteger(it.i) && it.i > 0 && Number.isInteger(it.q) && it.q >= 1 && it.q <= MAX_QTY) + if (clean.length === 0) return false + + const subject = 'Has recibido un regalo' + const fromLabel = SAFE_NAME.test(sender) ? sender : 'un amigo' + const body = `Regalo enviado por ${fromLabel}. Que lo disfrutes.` + + for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) { + const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT) + const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ') + const res = await executeSoapCommand(`.send items "${destination}" "${subject}" "${body}" ${itemsStr}`) + if (res === null) return false + } + return true +} diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts index f8382c0..407a35d 100644 --- a/web-next/lib/paid-services.ts +++ b/web-next/lib/paid-services.ts @@ -1,5 +1,6 @@ import { executeSoapCommand } from './soap' import { creditDPoints } from './dpoints' +import { sendGiftByMail } from './gift' import { checkFactionChangeEligibility } from './change-faction' import { checkTransferEligibility } from './transfer-character' import { @@ -102,6 +103,23 @@ export const PAID_SERVICES: Record = { extraFields: ['destination_account'], precheck: ({ accountId, email, character, body }) => checkTransferEligibility(accountId, email, character, body), }, + // Enviar regalo: al pagar por SumUp, envía por correo los ítems del carrito al + // personaje destino (a un amigo). `sender` = personaje de origen; `items` = JSON + // [{ i: item_id, q: cantidad }] que guardó el checkout en metadata. + 'send-gift': { + price: (m) => Promise.resolve(Number(m.amount)), + productName: (c) => `Regalo para ${c}`, + fulfill: (c, m) => { + let items: { i: number; q: number }[] = [] + try { + items = JSON.parse(m.items || '[]') + } catch { + return Promise.resolve(false) + } + return sendGiftByMail(c, m.sender || '', items) + }, + extraFields: [], + }, // Compra de PD (Adquirir PD): acredita puntos a la cuenta que hizo el pago. // El importe lo elige el usuario, por eso `price` lee metadata.amount. dpoints: { diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 793ca8d..91c59f3 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -341,7 +341,11 @@ "destination": "Destination account", "success": "Character {name} has been transferred to the destination account." }, - "alreadyProcessed": "Your payment is confirmed and the reward was already delivered." + "alreadyProcessed": "Your payment is confirmed and the reward was already delivered.", + "send-gift": { + "title": "Send gift", + "success": "The gift has been mailed to character {name}." + } }, "SecurityToken": { "title": "Security token", @@ -690,4 +694,4 @@ "title": "Page not found", "message": "It looks like the page you are looking for doesn't exist or isn't available." } -} \ No newline at end of file +} diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 7b7a49f..d73291e 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -341,7 +341,11 @@ "destination": "Cuenta de destino", "success": "El personaje {name} ha sido transferido a la cuenta de destino." }, - "alreadyProcessed": "Tu pago se ha confirmado y la recompensa ya fue entregada." + "alreadyProcessed": "Tu pago se ha confirmado y la recompensa ya fue entregada.", + "send-gift": { + "title": "Enviar regalo", + "success": "El regalo ha sido enviado por correo al personaje {name}." + } }, "SecurityToken": { "title": "Token de seguridad", @@ -690,4 +694,4 @@ "title": "Página no encontrada", "message": "Parece que la página que estás buscando no existe o no se encuentra disponible." } -} \ No newline at end of file +} diff --git a/web-next/sql/seed_giftitems.sql b/web-next/sql/seed_giftitems.sql new file mode 100644 index 0000000..13caaf4 --- /dev/null +++ b/web-next/sql/seed_giftitems.sql @@ -0,0 +1,20 @@ +-- Catálogo de la tienda / regalos (django_wow.home_category + home_item). +-- Usado por /send-gift (y la futura /store). Precios en euros; item_id = id del +-- ítem en el juego; image_url = icono (se muestra en la ficha del objeto). +-- Idempotente: reusa categorías/ítems por su nombre / item_id único. + +INSERT INTO home_category (name) VALUES + ('Monturas'), ('Mascotas'), ('Consumibles'), ('Misceláneo') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +INSERT INTO home_item (category_id, name, price, item_id, image_url) VALUES + ((SELECT id FROM home_category WHERE name='Monturas'), 'Riendas del protodraco azul', 5.00, 43599, 'https://wow.zamimg.com/images/wow/icons/large/ability_mount_drake_proto.jpg'), + ((SELECT id FROM home_category WHERE name='Monturas'), 'Riendas del draco rojo', 5.00, 44151, 'https://wow.zamimg.com/images/wow/icons/large/ability_mount_drake_red.jpg'), + ((SELECT id FROM home_category WHERE name='Mascotas'), 'Calabacín siniestro', 2.00, 44810, 'https://wow.zamimg.com/images/wow/icons/large/inv_misc_food_pumpkincookie.jpg'), + ((SELECT id FROM home_category WHERE name='Mascotas'), 'Cría de espormurciélago', 2.00, 34425, 'https://wow.zamimg.com/images/wow/icons/large/ability_hunter_pet_bat.jpg'), + ((SELECT id FROM home_category WHERE name='Consumibles'), 'Poción de curación rúnica', 0.50, 33447, 'https://wow.zamimg.com/images/wow/icons/large/inv_potion_167.jpg'), + ((SELECT id FROM home_category WHERE name='Consumibles'), 'Poción de maná rúnica', 0.50, 33448, 'https://wow.zamimg.com/images/wow/icons/large/inv_potion_137.jpg'), + ((SELECT id FROM home_category WHERE name='Misceláneo'), 'Bolsa de tela de niebla', 1.00, 41599, 'https://wow.zamimg.com/images/wow/icons/large/inv_misc_bag_29.jpg'), + ((SELECT id FROM home_category WHERE name='Misceláneo'), 'Caja de regalo', 1.00, 21519, 'https://wow.zamimg.com/images/wow/icons/large/inv_holiday_christmaspresent_01.jpg') +ON DUPLICATE KEY UPDATE + category_id = VALUES(category_id), name = VALUES(name), price = VALUES(price), image_url = VALUES(image_url);