Añadir /send-gift (Enviar regalo) pagado por SumUp
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<PageShell title="Enviar regalo">
|
||||||
|
<ServiceBox>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
La herramienta <span>Enviar regalo</span> te permite enviar regalos a tus amigos.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>Los objetos disponibles son los mismos de la tienda y puedes enviar varios objetos a la vez.</p>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
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 <span>SumUp</span>.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
Si aún no tienes Token de seguridad, puedes solicitarlo{' '}
|
||||||
|
<Link href="/security-token" target="_blank">
|
||||||
|
aquí
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<fieldset>
|
||||||
|
<legend>NOTA</legend>
|
||||||
|
<div className="separate2">
|
||||||
|
<p>
|
||||||
|
Por favor, asegúrate de haber escrito el nombre del personaje correcto ya que esta acción no es
|
||||||
|
reversible una vez realizada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<SendGiftForm
|
||||||
|
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
|
||||||
|
catalog={catalog}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
</ServiceBox>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
id="security-token"
|
||||||
|
maxLength={6}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required
|
||||||
|
/>{' '}
|
||||||
|
<span
|
||||||
|
className={`far ${show ? 'fa-eye-slash' : 'fa-eye'} toggle-token`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => 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<Map<number, CartEntry>>(new Map())
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 <p className="second-brown">No tienes personajes disponibles.</p>
|
||||||
|
if (catalog.length === 0) return <p className="second-brown">No hay objetos disponibles para regalar por ahora.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="centered">
|
||||||
|
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||||
|
<p>Escoge desde qué personaje se envía el regalo:</p>
|
||||||
|
<CharacterSelect characters={characters} value={source} onChange={setSource} placeholder="Personaje de origen" />
|
||||||
|
<br />
|
||||||
|
<p>Nombre del personaje que recibirá el regalo:</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={12}
|
||||||
|
placeholder="Personaje de destino"
|
||||||
|
value={destination}
|
||||||
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<p>Token de seguridad:</p>
|
||||||
|
<SecretInput value={token} onChange={setToken} placeholder="Token de seguridad" />
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{/* Catálogo de objetos */}
|
||||||
|
<div className="justified">
|
||||||
|
{catalog.map((cat) => (
|
||||||
|
<div key={cat.id}>
|
||||||
|
<h3 className="first-brown">{cat.name}</h3>
|
||||||
|
<div>
|
||||||
|
{cat.items.map((it) => (
|
||||||
|
<div className="item-box" key={it.id}>
|
||||||
|
<a
|
||||||
|
href={`https://www.wowhead.com/wotlk/item=${it.itemId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
<img className="item-img-box" src={it.imageUrl} alt={it.name} />
|
||||||
|
</a>
|
||||||
|
<div className="item-name second-brown">{it.name}</div>
|
||||||
|
<div className="yellow-info">
|
||||||
|
{it.price} {currency}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="store-add-button" onClick={() => addItem(it)}>
|
||||||
|
Añadir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{/* Carrito */}
|
||||||
|
<h3 className="first-brown">Carrito</h3>
|
||||||
|
{cart.size === 0 ? (
|
||||||
|
<p className="second-brown">Aún no has añadido objetos.</p>
|
||||||
|
) : (
|
||||||
|
<table className="cart-list max-center-table">
|
||||||
|
<tbody>
|
||||||
|
{[...cart.values()].map((e) => (
|
||||||
|
<tr key={e.item.id}>
|
||||||
|
<td>
|
||||||
|
<img className="item-img-box" src={e.item.imageUrl} alt={e.item.name} />
|
||||||
|
</td>
|
||||||
|
<td className="second-brown">{e.item.name}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={MAX_QTY}
|
||||||
|
value={e.qty}
|
||||||
|
onChange={(ev) => setQty(e.item.id, Number(ev.target.value))}
|
||||||
|
style={{ width: '4em' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="yellow-info">{Math.round(e.item.price * e.qty * 100) / 100} {currency}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="store-remove-button" onClick={() => removeItem(e.item.id)}>
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="centered">
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
Total: <span className="yellow-info">{total} {currency}</span> (SumUp)
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="store-send-button"
|
||||||
|
disabled={busy || cart.size === 0 || !source || !destination.trim() || !token.trim()}
|
||||||
|
>
|
||||||
|
{busy ? 'Enviando regalo' : 'ENVIAR REGALO'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<hr />
|
||||||
|
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
||||||
|
{error && <span className="red-form-response">{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<GiftCategory[]> {
|
||||||
|
try {
|
||||||
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||||
|
`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<number, GiftCategory>()
|
||||||
|
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<PricedCart | null> {
|
||||||
|
const qtyById = new Map<number, number>()
|
||||||
|
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<RowDataPacket[]>(
|
||||||
|
`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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { executeSoapCommand } from './soap'
|
import { executeSoapCommand } from './soap'
|
||||||
import { creditDPoints } from './dpoints'
|
import { creditDPoints } from './dpoints'
|
||||||
|
import { sendGiftByMail } from './gift'
|
||||||
import { checkFactionChangeEligibility } from './change-faction'
|
import { checkFactionChangeEligibility } from './change-faction'
|
||||||
import { checkTransferEligibility } from './transfer-character'
|
import { checkTransferEligibility } from './transfer-character'
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +103,23 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
|||||||
extraFields: ['destination_account'],
|
extraFields: ['destination_account'],
|
||||||
precheck: ({ accountId, email, character, body }) => checkTransferEligibility(accountId, email, character, body),
|
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.
|
// 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.
|
// El importe lo elige el usuario, por eso `price` lee metadata.amount.
|
||||||
dpoints: {
|
dpoints: {
|
||||||
|
|||||||
@@ -341,7 +341,11 @@
|
|||||||
"destination": "Destination account",
|
"destination": "Destination account",
|
||||||
"success": "Character {name} has been transferred to the 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": {
|
"SecurityToken": {
|
||||||
"title": "Security token",
|
"title": "Security token",
|
||||||
|
|||||||
@@ -341,7 +341,11 @@
|
|||||||
"destination": "Cuenta de destino",
|
"destination": "Cuenta de destino",
|
||||||
"success": "El personaje {name} ha sido transferido a la 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": {
|
"SecurityToken": {
|
||||||
"title": "Token de seguridad",
|
"title": "Token de seguridad",
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user