send-gift: rediseño al original — es la tienda, pero regalando
El original lo dice en su propio texto ("los objetos disponibles son los mismos
de la tienda") y lo confirma su JS, que usa #store-list y .store-add-button: el
regalo NO tiene catálogo propio, es la tienda con el correo a otro personaje.
Nosotros teníamos una tabla aparte (home_item) con 8 objetos y precios en euros.
Ahora send-gift monta StoreBrowser en modo `gift`: mismo catálogo (3068 ítems,
PD/PV), mismo carrito con cantidades y las cuatro formas de pago. Antes NO tenía
Stripe: solo SumUp, PD y PV.
Dos pasos, como el original: primero #char-select-div (personaje de origen,
destino, confirmación y token) y solo al pulsar "Mostrar Regalos" aparece el
catálogo. Se borran SendGiftForm, /api/gift/checkout y lib/gift (ya no los usa
nadie); la tabla home_item se queda en la BD, sin usar.
Tres cosas que el usuario pidió y que estaban mal:
- El formulario va con `noValidate`: los avisos los damos nosotros en rojo
(#show-gif-response), no el navegador.
- "Mostrar Regalos" ahora valida contra el SERVIDOR (que el personaje de origen
sea tuyo, que el destino exista y que el token sea correcto). Antes elegías
objetos para descubrir al final que el destino no existía.
- BUG REAL: el nombre del destino distinguía mayúsculas. `characters.name` es
`utf8mb4_bin`, así que "innakh" NO encontraba a "Innakh" (comprobado: 0
resultados; con COLLATE, 1). `findCharacterByName` busca sin distinguir y
devuelve el nombre CANÓNICO, que es el que va al comando SOAP.
La validación vive en lib/gift-check y la usan las dos rutas: /api/gift/check (el
botón) y /api/gift/send, que revalida porque el cliente puede saltarse el paso 1.
También se quita del texto "El pago se realiza mediante SumUp": el original no lo
dice y además ya era falso con cuatro formas de pago.
Verificado: innakh / INNAKH / InNaKh resuelven a "Innakh"; token malo y destino
inexistente salen en rojo sin pasar al catálogo. Con 500 PD de saldo de prueba,
2 copias de un ítem de 200 pasan el cobro (deliveryFailed por el worldserver
caído, con su reembolso) y 3 dan insufficientPd: el servidor cobra por copias.
Datos de prueba borrados.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { NoteLegend } from '@/components/NoteLegend'
|
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
import { redirect, Link } from '@/i18n/navigation'
|
import { getRealmName } from '@/lib/realm'
|
||||||
import { getSession } from '@/lib/session'
|
import { GiftPage } from '@/components/GiftPage'
|
||||||
import { getGameCharacters } from '@/lib/characters'
|
|
||||||
import { getDPointsBalance, getVPointsBalance } from '@/lib/dpoints'
|
|
||||||
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 dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -21,59 +14,5 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
|||||||
export default async function SendGiftPage({ params }: { params: Promise<{ locale: string }> }) {
|
export default async function SendGiftPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
const t = await getTranslations('CharService')
|
return <GiftPage locale={locale} realm={await getRealmName()} />
|
||||||
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session.bnetId) redirect({ href: '/log-in', locale })
|
|
||||||
if (!session.username) redirect({ href: '/select-account', locale })
|
|
||||||
|
|
||||||
const [chars, catalog, pdBalance, vpBalance] = await Promise.all([
|
|
||||||
getGameCharacters(session.accountId!),
|
|
||||||
getGiftCatalog(),
|
|
||||||
getDPointsBalance(session.accountId!),
|
|
||||||
getVPointsBalance(session.accountId!),
|
|
||||||
])
|
|
||||||
const currency = process.env.SUMUP_CURRENCY === 'EUR' ? '€' : process.env.SUMUP_CURRENCY || '€'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title={t('sendGift.pageTitle')}>
|
|
||||||
<ServiceBox>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
{t.rich('sendGift.intro', { s: (c) => <span>{c}</span> })}
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
{t('sendGift.explain1')}
|
|
||||||
</p>
|
|
||||||
<p>{t('sendGift.explain2')}</p>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
{t.rich('sendGift.explain3', { s: (c) => <span>{c}</span> })}
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
{t.rich('sendGift.securityTokenHint', { link: (c) => <Link href="/security-token" target="_blank">{c}</Link> })}
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<fieldset>
|
|
||||||
<NoteLegend />
|
|
||||||
<div className="separate2">
|
|
||||||
<p>
|
|
||||||
{t('sendGift.noteIrreversible')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<SendGiftForm
|
|
||||||
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
|
|
||||||
catalog={catalog}
|
|
||||||
currency={currency}
|
|
||||||
pdBalance={pdBalance}
|
|
||||||
vpBalance={vpBalance}
|
|
||||||
/>
|
|
||||||
</ServiceBox>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { getSession } from '@/lib/session'
|
||||||
|
import { checkGift } from '@/lib/gift-check'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida el paso 1 de "Enviar regalo" (botón "Mostrar Regalos"): que el
|
||||||
|
* personaje de origen sea tuyo, que el de destino exista y que el token sea
|
||||||
|
* correcto. Sin esto el usuario elegía objetos y solo descubría al final que el
|
||||||
|
* destino no existía o el token estaba mal, como pasaba antes.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.accountId || !session.username) {
|
||||||
|
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||||
|
}
|
||||||
|
let body = {}
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const r = await checkGift(session.accountId, body)
|
||||||
|
if ('error' in r) return Response.json({ success: false, error: r.error })
|
||||||
|
// Devuelve el nombre canónico para que el carrito envíe exactamente ese.
|
||||||
|
return Response.json({ success: true, destination: r.destination })
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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, sendGiftByMail, type CartLine } from '@/lib/gift'
|
|
||||||
import { payServiceWithDPoints, payServiceWithVPoints } from '@/lib/pay-with-dpoints'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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; locale?: 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.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const site = process.env.SITE_URL || ''
|
|
||||||
const items = priced.lines.map((l) => ({ i: l.itemId, q: l.qty }))
|
|
||||||
|
|
||||||
// Pago con saldo PD o PV: se descuenta el saldo y se envía el regalo al momento.
|
|
||||||
// PV es más caro que PD (son puntos gratis por votar).
|
|
||||||
if (String(body.provider) === 'pd' || String(body.provider) === 'vp') {
|
|
||||||
const useVp = String(body.provider) === 'vp'
|
|
||||||
const r = useVp
|
|
||||||
? await payServiceWithVPoints(session.accountId, priced.total, () => sendGiftByMail(destination, source, items))
|
|
||||||
: await payServiceWithDPoints(session.accountId, priced.total, () => sendGiftByMail(destination, source, items))
|
|
||||||
if (!r.success) return Response.json({ success: false, error: r.error, message: 'No se pudo completar el pago.' })
|
|
||||||
const locale = String(body.locale) === 'en' ? 'en' : 'es'
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
url: `${site}/${locale}/service-success?service=send-gift&provider=${useVp ? 'vp' : 'pd'}&name=${encodeURIComponent(destination)}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
|
|
||||||
const reference = randomUUID()
|
|
||||||
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,125 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { getSession } from '@/lib/session'
|
||||||
|
import { getGameCharacters, findCharacterByName } from '@/lib/characters'
|
||||||
|
import { checkGift } from '@/lib/gift-check'
|
||||||
|
import {
|
||||||
|
priceStoreCart,
|
||||||
|
purchaseStoreCart,
|
||||||
|
storeEuroTotal,
|
||||||
|
createStoreOrder,
|
||||||
|
storeConcept,
|
||||||
|
giftMail,
|
||||||
|
isValidCharName,
|
||||||
|
type StoreCartLine,
|
||||||
|
} from '@/lib/store'
|
||||||
|
import { CARD_MIN_EUR } from '@/lib/store-pricing'
|
||||||
|
import { createCheckoutSession } from '@/lib/stripe'
|
||||||
|
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
|
||||||
|
|
||||||
|
function safeLocale(v: unknown): string {
|
||||||
|
return v === 'en' ? 'en' : 'es'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envío de un regalo: es la compra de la tienda pero el correo va a OTRO
|
||||||
|
* personaje y diciendo quién lo manda. Mismo catálogo y mismo carrito
|
||||||
|
* (`priceStoreCart`), así que los precios se recalculan igual contra la BD.
|
||||||
|
*
|
||||||
|
* A diferencia de la tienda exige, como el original: personaje de origen de tu
|
||||||
|
* cuenta, personaje de destino que exista, y token de seguridad.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.accountId || !session.username) {
|
||||||
|
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
source?: string
|
||||||
|
destination?: string
|
||||||
|
items?: StoreCartLine[]
|
||||||
|
security_token?: string
|
||||||
|
provider?: string
|
||||||
|
locale?: string
|
||||||
|
} = {}
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = await checkGift(session.accountId, body)
|
||||||
|
if ('error' in check) return Response.json({ success: false, error: check.error })
|
||||||
|
const { source, destination } = check
|
||||||
|
|
||||||
|
const cart = await priceStoreCart(Array.isArray(body.items) ? body.items : [])
|
||||||
|
if (!cart) return Response.json({ success: false, error: 'emptyCart' })
|
||||||
|
|
||||||
|
const provider = String(body.provider ?? 'balance')
|
||||||
|
const mail = giftMail(source)
|
||||||
|
|
||||||
|
if (provider === 'stripe' || provider === 'sumup') {
|
||||||
|
const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
|
||||||
|
const minEur = CARD_MIN_EUR[provider]
|
||||||
|
if (amount < minEur) return Response.json({ success: false, error: 'amountTooLow', min: minEur })
|
||||||
|
|
||||||
|
const ref = randomUUID()
|
||||||
|
// El pedido se guarda a nombre del DESTINO: es a quien hay que entregarlo
|
||||||
|
// cuando la pasarela confirme el pago.
|
||||||
|
if (!(await createStoreOrder(ref, session.accountId, destination, cart))) {
|
||||||
|
return Response.json({ success: false, error: 'genericError' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = process.env.SITE_URL || ''
|
||||||
|
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
|
||||||
|
const productName = storeConcept(cart, {
|
||||||
|
bnetEmail: session.bnetEmail,
|
||||||
|
account: session.username,
|
||||||
|
character: `${source} → ${destination}`,
|
||||||
|
ip,
|
||||||
|
})
|
||||||
|
// `sender` lo necesita la entrega para redactar el correo del regalo.
|
||||||
|
const metadata = { service: 'send-gift', order_ref: ref, sender: source }
|
||||||
|
const back = `${site}/${safeLocale(body.locale)}/send-gift`
|
||||||
|
|
||||||
|
if (provider === 'sumup') {
|
||||||
|
if (!sumupConfigured()) return Response.json({ success: false, error: 'notConfigured' })
|
||||||
|
const r = await createSumUpCheckout({
|
||||||
|
accountId: session.accountId,
|
||||||
|
username: session.username || '',
|
||||||
|
email: session.bnetEmail || '',
|
||||||
|
acoreIp: ip,
|
||||||
|
amount,
|
||||||
|
points: 0,
|
||||||
|
reference: ref,
|
||||||
|
description: productName,
|
||||||
|
returnUrl: `${site}/service-success?service=send-gift&provider=sumup&ref=${ref}`,
|
||||||
|
service: 'send-gift',
|
||||||
|
characterName: destination,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
if (!r.success || !r.url) return Response.json({ success: false, error: r.error || 'sumupError' })
|
||||||
|
return Response.json({ success: true, url: r.url })
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await createCheckoutSession({
|
||||||
|
accountId: session.accountId,
|
||||||
|
username: session.username || '',
|
||||||
|
email: session.bnetEmail || '',
|
||||||
|
acoreIp: ip,
|
||||||
|
amount,
|
||||||
|
characterName: destination,
|
||||||
|
productName,
|
||||||
|
successUrl: `${site}/service-success?service=send-gift&name=${encodeURIComponent(destination)}`,
|
||||||
|
cancelUrl: back,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' })
|
||||||
|
return Response.json({ success: true, url: r.url })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saldo PD/PV: se cobra a TU cuenta y el correo va al personaje de destino.
|
||||||
|
const r = await purchaseStoreCart(session.accountId, destination, cart, mail)
|
||||||
|
if (!r.success) return Response.json({ success: false, error: r.error })
|
||||||
|
return Response.json({ success: true })
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { redirect, Link } from '@/i18n/navigation'
|
||||||
|
import { getSession } from '@/lib/session'
|
||||||
|
import { getGameCharacters } from '@/lib/characters'
|
||||||
|
import { getStoreBalances } from '@/lib/store'
|
||||||
|
import { PageShell } from '@/components/PageShell'
|
||||||
|
import { ServiceBox } from '@/components/ServiceBox'
|
||||||
|
import { NoteLegend } from '@/components/NoteLegend'
|
||||||
|
import { StoreBrowser } from '@/components/StoreBrowser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enviar regalo. Es la tienda regalando: mismo catálogo y mismo carrito (así lo
|
||||||
|
* dice el propio texto del original, «los objetos disponibles son los mismos de
|
||||||
|
* la tienda»), pero el correo lo recibe otro personaje y hace falta el token de
|
||||||
|
* seguridad. Por eso monta `StoreBrowser` en modo `gift` en vez de duplicarlo.
|
||||||
|
*/
|
||||||
|
export async function GiftPage({ locale, realm }: { locale: string; realm: string }) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.bnetId) redirect({ href: '/log-in', locale })
|
||||||
|
if (!session.username) redirect({ href: '/select-account', locale })
|
||||||
|
|
||||||
|
const [chars, balances] = await Promise.all([
|
||||||
|
getGameCharacters(session.accountId!),
|
||||||
|
getStoreBalances(session.accountId!),
|
||||||
|
])
|
||||||
|
const t = await getTranslations('CharService')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={t('sendGift.pageTitle')}>
|
||||||
|
<ServiceBox>
|
||||||
|
<br />
|
||||||
|
<p>{t.rich('sendGift.intro', { s: (c) => <span>{c}</span> })}</p>
|
||||||
|
<br />
|
||||||
|
<p>{t('sendGift.explain1')}</p>
|
||||||
|
<p>{t('sendGift.explain2')}</p>
|
||||||
|
<br />
|
||||||
|
<p>{t('sendGift.explain3')}</p>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
{t.rich('sendGift.securityTokenHint', {
|
||||||
|
link: (c) => (
|
||||||
|
<Link href="/security-token" target="_blank">
|
||||||
|
{c}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<fieldset>
|
||||||
|
<NoteLegend />
|
||||||
|
<div className="separate2">
|
||||||
|
<p>{t('sendGift.noteIrreversible')}</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<br />
|
||||||
|
</ServiceBox>
|
||||||
|
|
||||||
|
<StoreBrowser
|
||||||
|
mode="gift"
|
||||||
|
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
|
||||||
|
dp={balances.dp}
|
||||||
|
vp={balances.vp}
|
||||||
|
realm={realm}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/** Campo con ojo para mostrar/ocultar el token de seguridad. */
|
||||||
|
export 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
|
||||||
import type { GiftCategory, GiftItem } from '@/lib/gift'
|
|
||||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
|
||||||
import { PaymentMethodSelect, type PayMethod } from '@/components/PaymentMethodSelect'
|
|
||||||
import { WowheadLink } from '@/components/WowheadLink'
|
|
||||||
|
|
||||||
// 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 = '€',
|
|
||||||
pdBalance = 0,
|
|
||||||
vpBalance = 0,
|
|
||||||
}: {
|
|
||||||
characters: CharOption[]
|
|
||||||
catalog: GiftCategory[]
|
|
||||||
currency?: string
|
|
||||||
pdBalance?: number
|
|
||||||
vpBalance?: number
|
|
||||||
}) {
|
|
||||||
const t = useTranslations('CharService')
|
|
||||||
const tpay = useTranslations('Pay')
|
|
||||||
const locale = useLocale()
|
|
||||||
const [source, setSource] = useState('')
|
|
||||||
const [destination, setDestination] = useState('')
|
|
||||||
const [token, setToken] = useState('')
|
|
||||||
const [cart, setCart] = useState<Map<number, CartEntry>>(new Map())
|
|
||||||
const [method, setMethod] = useState<PayMethod>('pd')
|
|
||||||
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],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Catálogo aplanado y paginado: 4 objetos por fila, 2 filas (8) por página.
|
|
||||||
const PAGE_SIZE = 8
|
|
||||||
const allItems = useMemo(
|
|
||||||
() => catalog.flatMap((c) => c.items.map((it) => ({ ...it, category: c.name }))),
|
|
||||||
[catalog],
|
|
||||||
)
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const totalPages = Math.max(1, Math.ceil(allItems.length / PAGE_SIZE))
|
|
||||||
const currentPage = Math.min(page, totalPages)
|
|
||||||
const pageItems = allItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
|
||||||
|
|
||||||
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(t('sendGift.errMissingFields'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (cart.size === 0) {
|
|
||||||
setError(t('sendGift.errEmptyCart'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
t('sendGift.confirm', { destination: destination.trim(), total, currency }),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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: method,
|
|
||||||
locale,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
|
|
||||||
if (data.success && data.url) window.location.href = data.url
|
|
||||||
else {
|
|
||||||
const payErr = data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : undefined
|
|
||||||
setError(data.message || payErr || t('sendGift.errPayment'))
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError(t('sendGift.errUnexpected'))
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (characters.length === 0) return <p className="second-brown">{t('sendGift.noCharacters')}</p>
|
|
||||||
if (catalog.length === 0) return <p className="second-brown">{t('sendGift.noItems')}</p>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="centered">
|
|
||||||
<form noValidate onSubmit={handleSubmit} acceptCharset="utf-8">
|
|
||||||
<p>{t('sendGift.sourcePrompt')}</p>
|
|
||||||
<CharacterSelect characters={characters} value={source} onChange={setSource} placeholder={t('sendGift.sourcePlaceholder')} />
|
|
||||||
<br />
|
|
||||||
<p>{t('sendGift.destPrompt')}</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={12}
|
|
||||||
placeholder={t('sendGift.destPlaceholder')}
|
|
||||||
value={destination}
|
|
||||||
onChange={(e) => setDestination(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
<p>{t('sendGift.tokenPrompt')}</p>
|
|
||||||
<SecretInput value={token} onChange={setToken} placeholder={t('sendGift.tokenPlaceholder')} />
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
{/* Catálogo de objetos: cuadrícula centrada de 4 por fila + paginación */}
|
|
||||||
<h3 className="first-brown centered">{t('sendGift.availableItems')}</h3>
|
|
||||||
<div className="gift-grid">
|
|
||||||
{pageItems.map((it) => (
|
|
||||||
<div className="item-box" key={it.id}>
|
|
||||||
<WowheadLink id={it.itemId}>
|
|
||||||
<img className="item-img-box" src={it.imageUrl} alt={it.name} />
|
|
||||||
</WowheadLink>
|
|
||||||
<div className="third-brown" style={{ fontSize: '12px' }}>{it.category}</div>
|
|
||||||
<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)}>
|
|
||||||
{t('sendGift.add')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="gift-pagination">
|
|
||||||
<button type="button" onClick={() => setPage((p) => Math.max(1, Math.min(totalPages, p) - 1))} disabled={currentPage === 1}>
|
|
||||||
{t('sendGift.back')}
|
|
||||||
</button>
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={n}
|
|
||||||
className={n === currentPage ? 'active-page' : ''}
|
|
||||||
onClick={() => setPage(n)}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button type="button" onClick={() => setPage((p) => Math.min(totalPages, Math.min(totalPages, p) + 1))} disabled={currentPage === totalPages}>
|
|
||||||
{t('sendGift.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
{/* Carrito */}
|
|
||||||
<h3 className="first-brown">{t('sendGift.cart')}</h3>
|
|
||||||
{cart.size === 0 ? (
|
|
||||||
<p className="second-brown">{t('sendGift.cartEmpty')}</p>
|
|
||||||
) : (
|
|
||||||
<table className="cart-list max-center-table">
|
|
||||||
<tbody>
|
|
||||||
{[...cart.values()].map((e) => (
|
|
||||||
<tr key={e.item.id}>
|
|
||||||
<td>
|
|
||||||
<WowheadLink id={e.item.itemId}>
|
|
||||||
<img className="item-img-box" src={e.item.imageUrl} alt={e.item.name} />
|
|
||||||
</WowheadLink>
|
|
||||||
</td>
|
|
||||||
<td className="second-brown">
|
|
||||||
<WowheadLink id={e.item.itemId}>{e.item.name}</WowheadLink>
|
|
||||||
</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>
|
|
||||||
{t.rich('sendGift.total', { total, currency, s: (c) => <span className="yellow-info">{c}</span> })}
|
|
||||||
</p>
|
|
||||||
{total > 0 && <PaymentMethodSelect value={method} onChange={setMethod} priceEur={total} pdBalance={pdBalance} vpBalance={vpBalance} />}
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="store-send-button"
|
|
||||||
disabled={busy || cart.size === 0 || !source || !destination.trim() || !token.trim()}
|
|
||||||
>
|
|
||||||
{busy ? t('sendGift.submitting') : t('sendGift.submit')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<hr />
|
|
||||||
<div className="alert-message" style={{ display: error ? 'block' : 'none' }}>
|
|
||||||
{error && <span className="red-form-response">{error}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react'
|
import { Fragment, useEffect, useState } from 'react'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||||
|
import { SecretInput } from '@/components/SecretInput'
|
||||||
import { WowheadLink } from '@/components/WowheadLink'
|
import { WowheadLink } from '@/components/WowheadLink'
|
||||||
import { wowheadIcon } from '@/lib/wowhead'
|
import { wowheadIcon } from '@/lib/wowhead'
|
||||||
import { CARD_MIN_EUR, MAX_COPIES, lineEur, storeEuroTotal } from '@/lib/store-pricing'
|
import { CARD_MIN_EUR, MAX_COPIES, lineEur, storeEuroTotal } from '@/lib/store-pricing'
|
||||||
@@ -154,17 +155,31 @@ interface CartLine {
|
|||||||
copies: number
|
copies: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Árbol + carrito de la tienda. En modo `gift` es lo mismo pero regalando: el
|
||||||
|
* original hace justo esto ("los objetos disponibles son los mismos de la
|
||||||
|
* tienda"), en dos pasos —primero de quién a quién y el token, y solo después el
|
||||||
|
* catálogo— y el correo lo recibe otro personaje.
|
||||||
|
*/
|
||||||
export function StoreBrowser({
|
export function StoreBrowser({
|
||||||
characters,
|
characters,
|
||||||
dp,
|
dp,
|
||||||
vp,
|
vp,
|
||||||
realm,
|
realm,
|
||||||
|
mode = 'store',
|
||||||
}: {
|
}: {
|
||||||
characters: CharOption[]
|
characters: CharOption[]
|
||||||
dp: number
|
dp: number
|
||||||
vp: number
|
vp: number
|
||||||
realm: string
|
realm: string
|
||||||
|
mode?: 'store' | 'gift'
|
||||||
}) {
|
}) {
|
||||||
|
const gift = mode === 'gift'
|
||||||
|
const [destination, setDestination] = useState('')
|
||||||
|
const [confirmDest, setConfirmDest] = useState('')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [shown, setShown] = useState(false)
|
||||||
|
const [formError, setFormError] = useState('')
|
||||||
const t = useTranslations('Store')
|
const t = useTranslations('Store')
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const [character, setCharacter] = useState('')
|
const [character, setCharacter] = useState('')
|
||||||
@@ -175,10 +190,8 @@ export function StoreBrowser({
|
|||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [modal, setModal] = useState<{ ok: boolean; text: string } | null>(null)
|
const [modal, setModal] = useState<{ ok: boolean; text: string } | null>(null)
|
||||||
|
|
||||||
async function onSelect(name: string) {
|
async function loadCatalog() {
|
||||||
setCharacter(name)
|
if (catalog) return
|
||||||
setCart(new Map())
|
|
||||||
if (!name || catalog) return
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/store/catalog?locale=${encodeURIComponent(locale)}`, { credentials: 'same-origin' })
|
const res = await fetch(`/api/store/catalog?locale=${encodeURIComponent(locale)}`, { credentials: 'same-origin' })
|
||||||
@@ -191,6 +204,54 @@ export function StoreBrowser({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSelect(name: string) {
|
||||||
|
setCharacter(name)
|
||||||
|
setCart(new Map())
|
||||||
|
// En la tienda el catálogo sale al elegir personaje; en el regalo hay que
|
||||||
|
// decir antes a quién y con qué token ("Mostrar Regalos").
|
||||||
|
if (name && !gift) await loadCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paso 1 del regalo: valida y solo entonces enseña el catálogo.
|
||||||
|
*
|
||||||
|
* La confirmación del nombre se compara aquí (es cosa de la pantalla), pero
|
||||||
|
* que el personaje de destino exista y que el token sea correcto lo dice el
|
||||||
|
* SERVIDOR: si no, elegirías objetos para descubrir al final que el destino no
|
||||||
|
* existe. El nombre del destino puede ir en mayúsculas o minúsculas; el
|
||||||
|
* servidor devuelve el canónico y es el que se usa a partir de aquí.
|
||||||
|
*/
|
||||||
|
async function showGifts(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!character || !destination.trim() || !token.trim()) return setFormError(t('gift.errMissing'))
|
||||||
|
if (destination.trim().toLowerCase() !== confirmDest.trim().toLowerCase()) {
|
||||||
|
return setFormError(t('gift.errConfirm'))
|
||||||
|
}
|
||||||
|
setFormError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gift/check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ source: character, destination: destination.trim(), security_token: token }),
|
||||||
|
})
|
||||||
|
const data: { success?: boolean; error?: string; destination?: string } = await res.json()
|
||||||
|
if (!data.success) {
|
||||||
|
setFormError(t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.destination) setDestination(data.destination)
|
||||||
|
} catch {
|
||||||
|
setFormError(t('errors.generic'))
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
await loadCatalog()
|
||||||
|
setShown(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Volver a añadir un ítem ya en el carrito suma una copia (como send-gift).
|
// Volver a añadir un ítem ya en el carrito suma una copia (como send-gift).
|
||||||
function addItem(it: StoreItem) {
|
function addItem(it: StoreItem) {
|
||||||
setCart((prev) => {
|
setCart((prev) => {
|
||||||
@@ -259,22 +320,23 @@ export function StoreBrowser({
|
|||||||
async function send() {
|
async function send() {
|
||||||
if (sending || cart.size === 0) return
|
if (sending || cart.size === 0) return
|
||||||
const card = payingCard
|
const card = payingCard
|
||||||
|
const who = gift ? destination.trim() : character
|
||||||
const ok = card
|
const ok = card
|
||||||
? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character }))
|
? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character: who }))
|
||||||
: window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))
|
: window.confirm(t('confirm', { character: who, pd: pdTotal, vp: vpTotal }))
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/store/send', {
|
const items = lines.map((l) => ({ id: l.it.id, copies: l.copies }))
|
||||||
|
const res = await fetch(gift ? '/api/gift/send' : '/api/store/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(
|
||||||
character,
|
gift
|
||||||
items: lines.map((l) => ({ id: l.it.id, copies: l.copies })),
|
? { source: character, destination: destination.trim(), security_token: token, items, provider: activeMethod, locale }
|
||||||
provider: activeMethod,
|
: { character, items, provider: activeMethod, locale },
|
||||||
locale,
|
),
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
|
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
|
||||||
if (data.success && data.url) {
|
if (data.success && data.url) {
|
||||||
@@ -284,7 +346,7 @@ export function StoreBrowser({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setModal({ ok: true, text: t('successMsg', { character }) })
|
setModal({ ok: true, text: t(gift ? 'gift.successMsg' : 'successMsg', { character: gift ? destination.trim() : character }) })
|
||||||
setCart(new Map())
|
setCart(new Map())
|
||||||
} else if (data.error === 'amountTooLow') {
|
} else if (data.error === 'amountTooLow') {
|
||||||
setModal({ ok: false, text: t('errors.amountTooLow', { min: eurNumber(data.min ?? 1, locale) }) })
|
setModal({ ok: false, text: t('errors.amountTooLow', { min: eurNumber(data.min ?? 1, locale) }) })
|
||||||
@@ -300,19 +362,74 @@ export function StoreBrowser({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="centered">
|
{/* Paso 1 del regalo (#char-select-div en el original): de quién a quién y
|
||||||
<br />
|
token. Desaparece al pulsar "Mostrar Regalos" y deja sitio al catálogo. */}
|
||||||
<p>{t('choosePlayer')}</p>
|
{gift && !shown && (
|
||||||
<br />
|
<div className="centered" id="char-select-div">
|
||||||
<CharacterSelect characters={characters} value={character} onChange={onSelect} placeholder={t('selectPlaceholder')} className="store-select" />
|
<br />
|
||||||
<p className="third-brown pay-method-balance">
|
<p>{t('gift.choosePlayer')}</p>
|
||||||
<span className="dp-color">PD</span>: {dp} · <span className="vp-color">PV</span>: {vp}
|
<br />
|
||||||
</p>
|
<form noValidate onSubmit={showGifts} acceptCharset="utf-8">
|
||||||
</div>
|
<CharacterSelect characters={characters} value={character} onChange={onSelect} placeholder={t('selectPlaceholder')} />
|
||||||
|
<table className="middle-center-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={12}
|
||||||
|
value={destination}
|
||||||
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
|
placeholder={t('gift.destPlaceholder')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={12}
|
||||||
|
value={confirmDest}
|
||||||
|
onChange={(e) => setConfirmDest(e.target.value)}
|
||||||
|
placeholder={t('gift.confirmPlaceholder')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<SecretInput value={token} onChange={setToken} placeholder={t('gift.tokenPlaceholder')} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<button type="submit" className="show-gift-button" disabled={loading}>
|
||||||
|
{loading ? t('gift.showing') : t('gift.show')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
<hr />
|
||||||
|
{formError && <div className="alert-message red-form-response" id="show-gif-response">{formError}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!gift && (
|
||||||
|
<div className="centered">
|
||||||
|
<br />
|
||||||
|
<p>{t('choosePlayer')}</p>
|
||||||
|
<br />
|
||||||
|
<CharacterSelect characters={characters} value={character} onChange={onSelect} placeholder={t('selectPlaceholder')} className="store-select" />
|
||||||
|
<p className="third-brown pay-method-balance">
|
||||||
|
<span className="dp-color">PD</span>: {dp} · <span className="vp-color">PV</span>: {vp}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && <p className="centered second-brown">{t('loading')}</p>}
|
{loading && <p className="centered second-brown">{t('loading')}</p>}
|
||||||
|
|
||||||
{catalog && !loading && (
|
{catalog && !loading && (!gift || shown) && (
|
||||||
<div className="box-content" id="store-div">
|
<div className="box-content" id="store-div">
|
||||||
<ul id="store-list">
|
<ul id="store-list">
|
||||||
{catalog.map((c) => (
|
{catalog.map((c) => (
|
||||||
|
|||||||
@@ -23,6 +23,30 @@ export async function getGameCharacters(accountId: number): Promise<GameCharacte
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Id de la cuenta dueña de un personaje por su nombre (único), o null. */
|
/** Id de la cuenta dueña de un personaje por su nombre (único), o null. */
|
||||||
|
/**
|
||||||
|
* Busca un personaje por nombre SIN distinguir mayúsculas y devuelve su nombre
|
||||||
|
* CANÓNICO (el de la BD) además de su cuenta.
|
||||||
|
*
|
||||||
|
* El COLLATE no es adorno: `characters.name` es `utf8mb4_bin`, o sea sensible a
|
||||||
|
* mayúsculas, y sin él "innakh" no encuentra a "Innakh". Y hay que quedarse con
|
||||||
|
* el nombre de la BD porque es el que va al comando SOAP del correo.
|
||||||
|
*/
|
||||||
|
export async function findCharacterByName(
|
||||||
|
name: string,
|
||||||
|
): Promise<{ name: string; account: number } | null> {
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
try {
|
||||||
|
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||||
|
'SELECT name, account FROM characters WHERE name = ? COLLATE utf8mb4_general_ci LIMIT 1',
|
||||||
|
[trimmed],
|
||||||
|
)
|
||||||
|
return rows[0] ? { name: String(rows[0].name), account: Number(rows[0].account) } : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAccountIdByCharacterName(name: string): Promise<number | null> {
|
export async function getAccountIdByCharacterName(name: string): Promise<number | null> {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getGameCharacters, findCharacterByName } from './characters'
|
||||||
|
import { checkSecurityToken } from './security-token'
|
||||||
|
import { isValidCharName } from './store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprueba que un regalo se puede enviar: personaje de origen tuyo, personaje
|
||||||
|
* de destino existente y token de seguridad correcto.
|
||||||
|
*
|
||||||
|
* La usan las DOS rutas: `/api/gift/check` (el botón "Mostrar Regalos", que
|
||||||
|
* valida antes de enseñar el catálogo, como el original) y `/api/gift/send`, que
|
||||||
|
* vuelve a validarlo porque el cliente puede saltarse el primer paso.
|
||||||
|
*
|
||||||
|
* Devuelve el nombre CANÓNICO del destino: el usuario puede escribirlo en
|
||||||
|
* mayúsculas, minúsculas o mezcladas, y al correo tiene que ir el de la BD.
|
||||||
|
*/
|
||||||
|
export async function checkGift(
|
||||||
|
accountId: number,
|
||||||
|
body: { source?: string; destination?: string; security_token?: string },
|
||||||
|
): Promise<{ source: string; destination: string } | { error: string }> {
|
||||||
|
const source = String(body.source ?? '').trim()
|
||||||
|
const destination = String(body.destination ?? '').trim()
|
||||||
|
const token = String(body.security_token ?? '').trim()
|
||||||
|
|
||||||
|
if (!source || !destination || !token) return { error: 'missingFields' }
|
||||||
|
if (!isValidCharName(destination)) return { error: 'invalidDestination' }
|
||||||
|
|
||||||
|
const chars = await getGameCharacters(accountId)
|
||||||
|
if (!chars.find((c) => c.name === source)) return { error: 'invalidSource' }
|
||||||
|
|
||||||
|
const dest = await findCharacterByName(destination)
|
||||||
|
if (!dest) return { error: 'destinationNotFound' }
|
||||||
|
|
||||||
|
if (!(await checkSecurityToken(accountId, token))) return { error: 'invalidToken' }
|
||||||
|
|
||||||
|
return { source, destination: dest.name }
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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,8 +1,7 @@
|
|||||||
import { executeSoapCommand } from './soap'
|
import { executeSoapCommand } from './soap'
|
||||||
import { creditDPoints } from './dpoints'
|
import { creditDPoints } from './dpoints'
|
||||||
import { sendGiftByMail } from './gift'
|
|
||||||
import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild'
|
import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild'
|
||||||
import { fulfillStoreOrder } from './store'
|
import { fulfillStoreOrder, giftMail } from './store'
|
||||||
import { checkFactionChangeEligibility } from './change-faction'
|
import { checkFactionChangeEligibility } from './change-faction'
|
||||||
import { checkTransferEligibility } from './transfer-character'
|
import { checkTransferEligibility } from './transfer-character'
|
||||||
import {
|
import {
|
||||||
@@ -125,21 +124,14 @@ 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
|
// Enviar regalo: es una compra de la tienda cuyo correo va a OTRO personaje y
|
||||||
// personaje destino (a un amigo). `sender` = personaje de origen; `items` = JSON
|
// dice quién lo manda. El carrito se guarda en home_store_order (order_ref) y
|
||||||
// [{ i: item_id, q: cantidad }] que guardó el checkout en metadata.
|
// se entrega igual que la tienda, con su reembolso parcial si falla a medias.
|
||||||
|
// `sender` = personaje de origen, para redactar el correo.
|
||||||
'send-gift': {
|
'send-gift': {
|
||||||
price: (m) => Promise.resolve(Number(m.amount)),
|
price: (m) => Promise.resolve(Number(m.amount)),
|
||||||
productName: (c) => `Regalo para ${c}`,
|
productName: (c) => `Regalo para ${c}`,
|
||||||
fulfill: (c, m) => {
|
fulfill: (c, m) => fulfillStoreOrder(c, m.order_ref || '', giftMail(m.sender || '')),
|
||||||
let items: { i: number; q: number }[] = []
|
|
||||||
try {
|
|
||||||
items = JSON.parse(m.items || '[]')
|
|
||||||
} catch {
|
|
||||||
return Promise.resolve(false)
|
|
||||||
}
|
|
||||||
return sendGiftByMail(c, m.sender || '', items)
|
|
||||||
},
|
|
||||||
extraFields: [],
|
extraFields: [],
|
||||||
},
|
},
|
||||||
// Renombrar hermandad (1000 PD = 10 €): al pagar, aplica `.guild rename`.
|
// Renombrar hermandad (1000 PD = 10 €): al pagar, aplica `.guild rename`.
|
||||||
|
|||||||
+33
-5
@@ -14,6 +14,11 @@ import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } f
|
|||||||
|
|
||||||
export type Currency = 'pd' | 'pv'
|
export type Currency = 'pd' | 'pv'
|
||||||
const SAFE_NAME = /^[A-Za-z]{1,12}$/
|
const SAFE_NAME = /^[A-Za-z]{1,12}$/
|
||||||
|
|
||||||
|
/** Nombre de personaje válido de WoW. Evita inyección en el comando SOAP. */
|
||||||
|
export function isValidCharName(name: string): boolean {
|
||||||
|
return SAFE_NAME.test(name)
|
||||||
|
}
|
||||||
const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore
|
const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore
|
||||||
|
|
||||||
export interface StoreItem {
|
export interface StoreItem {
|
||||||
@@ -291,6 +296,23 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart
|
|||||||
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
|
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texto del correo. El regalo lo cambia para decir quién lo envía; sin esto el
|
||||||
|
* destinatario recibiría un correo de "Tienda" sin saber de quién viene.
|
||||||
|
*/
|
||||||
|
export interface Mail {
|
||||||
|
subject: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Correo de un regalo: dice quién lo manda (como el original). */
|
||||||
|
export function giftMail(sender: string): Mail {
|
||||||
|
return {
|
||||||
|
subject: 'Regalo',
|
||||||
|
body: `¡Has recibido un regalo de ${sender}! ¡Que lo disfrutes!`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */
|
/** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */
|
||||||
interface CartEntry {
|
interface CartEntry {
|
||||||
i: number
|
i: number
|
||||||
@@ -320,6 +342,7 @@ export async function purchaseStoreCart(
|
|||||||
accountId: number,
|
accountId: number,
|
||||||
character: string,
|
character: string,
|
||||||
cart: PricedCart,
|
cart: PricedCart,
|
||||||
|
mail?: Mail,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' }
|
if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' }
|
||||||
if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' }
|
if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' }
|
||||||
@@ -351,7 +374,7 @@ export async function purchaseStoreCart(
|
|||||||
// lo que no se envió: los correos que ya salieron no se pueden recuperar, y
|
// lo que no se envió: los correos que ya salieron no se pueden recuperar, y
|
||||||
// devolver el carrito entero regalaría lo ya entregado.
|
// devolver el carrito entero regalaría lo ya entregado.
|
||||||
const entries = cartEntries(cart)
|
const entries = cartEntries(cart)
|
||||||
const sent = await sendStoreItems(character, entries)
|
const sent = await sendStoreItems(character, entries, mail)
|
||||||
if (sent < entries.length) {
|
if (sent < entries.length) {
|
||||||
const left = entries.slice(sent)
|
const left = entries.slice(sent)
|
||||||
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
|
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
|
||||||
@@ -429,6 +452,7 @@ export async function createStoreOrder(
|
|||||||
export async function fulfillStoreOrder(
|
export async function fulfillStoreOrder(
|
||||||
character: string,
|
character: string,
|
||||||
ref: string,
|
ref: string,
|
||||||
|
mail?: Mail,
|
||||||
): Promise<{ ok: boolean; refundEur?: number }> {
|
): Promise<{ ok: boolean; refundEur?: number }> {
|
||||||
if (!ref) return { ok: false }
|
if (!ref) return { ok: false }
|
||||||
let rows: RowDataPacket[] = []
|
let rows: RowDataPacket[] = []
|
||||||
@@ -457,7 +481,7 @@ export async function fulfillStoreOrder(
|
|||||||
if (entries.length === 0) return { ok: false }
|
if (entries.length === 0) return { ok: false }
|
||||||
const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv'))
|
const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv'))
|
||||||
|
|
||||||
const sent = await sendStoreItems(target, entries)
|
const sent = await sendStoreItems(target, entries, mail)
|
||||||
if (sent === entries.length) return { ok: true }
|
if (sent === entries.length) return { ok: true }
|
||||||
|
|
||||||
if (!priced) {
|
if (!priced) {
|
||||||
@@ -486,13 +510,17 @@ export async function fulfillStoreOrder(
|
|||||||
* resto. Cada correo es un `.send items`, así que el troceo es atómico por
|
* resto. Cada correo es un `.send items`, así que el troceo es atómico por
|
||||||
* correo: un chunk sale entero o no sale.
|
* correo: un chunk sale entero o no sale.
|
||||||
*/
|
*/
|
||||||
async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise<number> {
|
async function sendStoreItems(
|
||||||
|
character: string,
|
||||||
|
items: { i: number; q: number }[],
|
||||||
|
mail?: Mail,
|
||||||
|
): Promise<number> {
|
||||||
if (!SAFE_NAME.test(character)) return 0
|
if (!SAFE_NAME.test(character)) return 0
|
||||||
// Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no
|
// Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no
|
||||||
// enviar nada a enviar de menos y descuadrar el reembolso por índice.
|
// enviar nada a enviar de menos y descuadrar el reembolso por índice.
|
||||||
if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0
|
if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0
|
||||||
const subject = 'Tienda'
|
const subject = mail?.subject ?? 'Tienda'
|
||||||
const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
|
const body = mail?.body ?? 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
|
||||||
let sent = 0
|
let sent = 0
|
||||||
for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) {
|
for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) {
|
||||||
const chunk = items.slice(n, n + MAIL_ITEM_LIMIT)
|
const chunk = items.slice(n, n + MAIL_ITEM_LIMIT)
|
||||||
|
|||||||
@@ -763,7 +763,7 @@
|
|||||||
"intro": "The <s>Send gift</s> tool lets you send gifts to your friends.",
|
"intro": "The <s>Send gift</s> tool lets you send gifts to your friends.",
|
||||||
"explain1": "When you choose which character sends the gift and the character that will receive it, you will be able to see the list of items available to send as a gift.",
|
"explain1": "When you choose which character sends the gift and the character that will receive it, you will be able to see the list of items available to send as a gift.",
|
||||||
"explain2": "The available items are the same as in the store, and you can send several items at once.",
|
"explain2": "The available items are the same as in the store, and you can send several items at once.",
|
||||||
"explain3": "When you use the SEND GIFT button, a mail will be sent to the destination character with the selected items, indicating who gave it. Payment is made through <s>SumUp</s>.",
|
"explain3": "When you use the SEND GIFT button, a mail will be sent to the destination character with the selected items, indicating who gave it.",
|
||||||
"securityTokenHint": "If you don't have a Security Token yet, you can request it <link>here</link>.",
|
"securityTokenHint": "If you don't have a Security Token yet, you can request it <link>here</link>.",
|
||||||
"noteIrreversible": "Please make sure you have entered the correct character name, as this action cannot be reversed once performed.",
|
"noteIrreversible": "Please make sure you have entered the correct character name, as this action cannot be reversed once performed.",
|
||||||
"noCharacters": "You don't have any characters available.",
|
"noCharacters": "You don't have any characters available.",
|
||||||
@@ -1965,7 +1965,12 @@
|
|||||||
"invalidCharacter": "Invalid character.",
|
"invalidCharacter": "Invalid character.",
|
||||||
"emptyCart": "Your cart is empty.",
|
"emptyCart": "Your cart is empty.",
|
||||||
"generic": "An error occurred. Please try again.",
|
"generic": "An error occurred. Please try again.",
|
||||||
"amountTooLow": "The minimum amount to pay by card is {min} €."
|
"amountTooLow": "The minimum amount to pay by card is {min} €.",
|
||||||
|
"missingFields": "Fill in the source character, the target one and the security token.",
|
||||||
|
"invalidDestination": "The target character name is not valid.",
|
||||||
|
"invalidSource": "The source character does not belong to your account.",
|
||||||
|
"destinationNotFound": "The target character does not exist.",
|
||||||
|
"invalidToken": "The security token is not valid."
|
||||||
},
|
},
|
||||||
"newsTitle": "News",
|
"newsTitle": "News",
|
||||||
"newsIntro": "List of additions made based on the community's suggestions in our forum.",
|
"newsIntro": "List of additions made based on the community's suggestions in our forum.",
|
||||||
@@ -1976,6 +1981,17 @@
|
|||||||
"paySumUp": "Card (SumUp)",
|
"paySumUp": "Card (SumUp)",
|
||||||
"minCard": "Minimum {min} €",
|
"minCard": "Minimum {min} €",
|
||||||
"confirmCard": "Pay {eur} € by card and send the items to {character}?",
|
"confirmCard": "Pay {eur} € by card and send the items to {character}?",
|
||||||
"amountTooLow": "The minimum amount to pay by card is {min} €."
|
"amountTooLow": "The minimum amount to pay by card is {min} €.",
|
||||||
|
"gift": {
|
||||||
|
"choosePlayer": "Choose the character that will send the gift",
|
||||||
|
"destPlaceholder": "Target character name",
|
||||||
|
"confirmPlaceholder": "Confirm target character name",
|
||||||
|
"tokenPlaceholder": "Security token",
|
||||||
|
"show": "Show Gifts",
|
||||||
|
"showing": "Showing gifts",
|
||||||
|
"errMissing": "Fill in the source character, the target one and the security token.",
|
||||||
|
"errConfirm": "The target character name does not match the confirmation.",
|
||||||
|
"successMsg": "Gift sent to {character}! They will receive it in their in-game mail."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -763,7 +763,7 @@
|
|||||||
"intro": "La herramienta <s>Enviar regalo</s> te permite enviar regalos a tus amigos.",
|
"intro": "La herramienta <s>Enviar regalo</s> te permite enviar regalos a tus amigos.",
|
||||||
"explain1": "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.",
|
"explain1": "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.",
|
||||||
"explain2": "Los objetos disponibles son los mismos de la tienda y puedes enviar varios objetos a la vez.",
|
"explain2": "Los objetos disponibles son los mismos de la tienda y puedes enviar varios objetos a la vez.",
|
||||||
"explain3": "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 <s>SumUp</s>.",
|
"explain3": "Al usar el botón ENVIAR REGALO se enviará un correo al personaje destino con los ítems seleccionados, indicando quién lo ha regalado.",
|
||||||
"securityTokenHint": "Si aún no tienes Token de seguridad, puedes solicitarlo <link>aquí</link>.",
|
"securityTokenHint": "Si aún no tienes Token de seguridad, puedes solicitarlo <link>aquí</link>.",
|
||||||
"noteIrreversible": "Por favor, asegúrate de haber escrito el nombre del personaje correcto ya que esta acción no es reversible una vez realizada.",
|
"noteIrreversible": "Por favor, asegúrate de haber escrito el nombre del personaje correcto ya que esta acción no es reversible una vez realizada.",
|
||||||
"noCharacters": "No tienes personajes disponibles.",
|
"noCharacters": "No tienes personajes disponibles.",
|
||||||
@@ -1965,7 +1965,12 @@
|
|||||||
"invalidCharacter": "El personaje no es válido.",
|
"invalidCharacter": "El personaje no es válido.",
|
||||||
"emptyCart": "Tu carrito está vacío.",
|
"emptyCart": "Tu carrito está vacío.",
|
||||||
"generic": "Ha ocurrido un error. Inténtalo de nuevo.",
|
"generic": "Ha ocurrido un error. Inténtalo de nuevo.",
|
||||||
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €."
|
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €.",
|
||||||
|
"missingFields": "Completa el personaje de origen, el de destino y el token de seguridad.",
|
||||||
|
"invalidDestination": "El nombre del personaje de destino no es válido.",
|
||||||
|
"invalidSource": "El personaje de origen no pertenece a tu cuenta.",
|
||||||
|
"destinationNotFound": "El personaje de destino no existe.",
|
||||||
|
"invalidToken": "El token de seguridad no es válido."
|
||||||
},
|
},
|
||||||
"newsTitle": "Novedades",
|
"newsTitle": "Novedades",
|
||||||
"newsIntro": "Lista de adiciones realizadas en base a las sugerencias de la comunidad en nuestro foro.",
|
"newsIntro": "Lista de adiciones realizadas en base a las sugerencias de la comunidad en nuestro foro.",
|
||||||
@@ -1976,6 +1981,17 @@
|
|||||||
"paySumUp": "Tarjeta (SumUp)",
|
"paySumUp": "Tarjeta (SumUp)",
|
||||||
"minCard": "Mínimo {min} €",
|
"minCard": "Mínimo {min} €",
|
||||||
"confirmCard": "¿Pagar {eur} € con tarjeta y enviar los objetos a {character}?",
|
"confirmCard": "¿Pagar {eur} € con tarjeta y enviar los objetos a {character}?",
|
||||||
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €."
|
"amountTooLow": "El importe mínimo para pagar con tarjeta es {min} €.",
|
||||||
|
"gift": {
|
||||||
|
"choosePlayer": "Escoge el personaje que enviará el regalo",
|
||||||
|
"destPlaceholder": "Nombre del personaje destino",
|
||||||
|
"confirmPlaceholder": "Confirmar nombre del personaje destino",
|
||||||
|
"tokenPlaceholder": "Token de seguridad",
|
||||||
|
"show": "Mostrar Regalos",
|
||||||
|
"showing": "Mostrando regalos",
|
||||||
|
"errMissing": "Completa el personaje de origen, el de destino y el token de seguridad.",
|
||||||
|
"errConfirm": "El nombre del personaje destino no coincide con la confirmación.",
|
||||||
|
"successMsg": "¡Regalo enviado a {character}! Lo recibirá en su correo del juego."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user