diff --git a/web-next/app/[locale]/send-gift/page.tsx b/web-next/app/[locale]/send-gift/page.tsx index d2e4b47..e32f70e 100644 --- a/web-next/app/[locale]/send-gift/page.tsx +++ b/web-next/app/[locale]/send-gift/page.tsx @@ -1,14 +1,7 @@ import type { Metadata } from 'next' -import { NoteLegend } from '@/components/NoteLegend' import { getTranslations, setRequestLocale } from 'next-intl/server' -import { redirect, Link } from '@/i18n/navigation' -import { getSession } from '@/lib/session' -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' +import { getRealmName } from '@/lib/realm' +import { GiftPage } from '@/components/GiftPage' 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 }> }) { const { locale } = await params setRequestLocale(locale) - const t = await getTranslations('CharService') - - 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 ( - - -
-

- {t.rich('sendGift.intro', { s: (c) => {c} })} -

-
-

- {t('sendGift.explain1')} -

-

{t('sendGift.explain2')}

-
-

- {t.rich('sendGift.explain3', { s: (c) => {c} })} -

-
-

- {t.rich('sendGift.securityTokenHint', { link: (c) => {c} })} -

-
-
- -
-

- {t('sendGift.noteIrreversible')} -

-
-
-
- - ({ name: c.name, classCss: c.classCss }))} - catalog={catalog} - currency={currency} - pdBalance={pdBalance} - vpBalance={vpBalance} - /> -
-
- ) + return } diff --git a/web-next/app/api/gift/check/route.ts b/web-next/app/api/gift/check/route.ts new file mode 100644 index 0000000..5a3d2f5 --- /dev/null +++ b/web-next/app/api/gift/check/route.ts @@ -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 }) +} diff --git a/web-next/app/api/gift/checkout/route.ts b/web-next/app/api/gift/checkout/route.ts deleted file mode 100644 index 70f8568..0000000 --- a/web-next/app/api/gift/checkout/route.ts +++ /dev/null @@ -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 }) -} diff --git a/web-next/app/api/gift/send/route.ts b/web-next/app/api/gift/send/route.ts new file mode 100644 index 0000000..906ab02 --- /dev/null +++ b/web-next/app/api/gift/send/route.ts @@ -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 }) +} diff --git a/web-next/components/GiftPage.tsx b/web-next/components/GiftPage.tsx new file mode 100644 index 0000000..74e2715 --- /dev/null +++ b/web-next/components/GiftPage.tsx @@ -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 ( + + +
+

{t.rich('sendGift.intro', { s: (c) => {c} })}

+
+

{t('sendGift.explain1')}

+

{t('sendGift.explain2')}

+
+

{t('sendGift.explain3')}

+
+

+ {t.rich('sendGift.securityTokenHint', { + link: (c) => ( + + {c} + + ), + })} +

+
+
+ +
+

{t('sendGift.noteIrreversible')}

+
+
+
+
+ + ({ name: c.name, classCss: c.classCss }))} + dp={balances.dp} + vp={balances.vp} + realm={realm} + /> +
+ ) +} diff --git a/web-next/components/SecretInput.tsx b/web-next/components/SecretInput.tsx new file mode 100644 index 0000000..6a2cc44 --- /dev/null +++ b/web-next/components/SecretInput.tsx @@ -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 ( + <> + onChange(e.target.value)} + placeholder={placeholder} + required + />{' '} + setShow((s) => !s)} + /> + + ) +} diff --git a/web-next/components/SendGiftForm.tsx b/web-next/components/SendGiftForm.tsx deleted file mode 100644 index 15223a7..0000000 --- a/web-next/components/SendGiftForm.tsx +++ /dev/null @@ -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 ( - <> - onChange(e.target.value)} - placeholder={placeholder} - required - />{' '} - 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>(new Map()) - const [method, setMethod] = useState('pd') - const [busy, setBusy] = useState(false) - const [error, setError] = useState(null) - - const total = useMemo( - () => Math.round([...cart.values()].reduce((s, e) => s + e.item.price * e.qty, 0) * 100) / 100, - [cart], - ) - - // 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

{t('sendGift.noCharacters')}

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

{t('sendGift.noItems')}

- - return ( -
-
-

{t('sendGift.sourcePrompt')}

- -
-

{t('sendGift.destPrompt')}

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

{t('sendGift.tokenPrompt')}

- -
- - {/* Catálogo de objetos: cuadrícula centrada de 4 por fila + paginación */} -

{t('sendGift.availableItems')}

-
- {pageItems.map((it) => ( -
- - {it.name} - -
{it.category}
-
{it.name}
-
- {it.price} {currency} -
- -
- ))} -
- {totalPages > 1 && ( -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => ( - - ))} - -
- )} -
- - {/* Carrito */} -

{t('sendGift.cart')}

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

{t('sendGift.cartEmpty')}

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

- {t.rich('sendGift.total', { total, currency, s: (c) => {c} })} -

- {total > 0 && } -
- -
- -
-
- {error && {error}} -
-
- ) -} diff --git a/web-next/components/StoreBrowser.tsx b/web-next/components/StoreBrowser.tsx index d30548a..d8c5dba 100644 --- a/web-next/components/StoreBrowser.tsx +++ b/web-next/components/StoreBrowser.tsx @@ -3,6 +3,7 @@ import { Fragment, useEffect, useState } from 'react' import { useTranslations, useLocale } from 'next-intl' import { CharacterSelect, type CharOption } from '@/components/CharacterSelect' +import { SecretInput } from '@/components/SecretInput' import { WowheadLink } from '@/components/WowheadLink' import { wowheadIcon } from '@/lib/wowhead' import { CARD_MIN_EUR, MAX_COPIES, lineEur, storeEuroTotal } from '@/lib/store-pricing' @@ -154,17 +155,31 @@ interface CartLine { 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({ characters, dp, vp, realm, + mode = 'store', }: { characters: CharOption[] dp: number vp: number 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 locale = useLocale() const [character, setCharacter] = useState('') @@ -175,10 +190,8 @@ export function StoreBrowser({ const [sending, setSending] = useState(false) const [modal, setModal] = useState<{ ok: boolean; text: string } | null>(null) - async function onSelect(name: string) { - setCharacter(name) - setCart(new Map()) - if (!name || catalog) return + async function loadCatalog() { + if (catalog) return setLoading(true) try { 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). function addItem(it: StoreItem) { setCart((prev) => { @@ -259,22 +320,23 @@ export function StoreBrowser({ async function send() { if (sending || cart.size === 0) return const card = payingCard + const who = gift ? destination.trim() : character const ok = card - ? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character })) - : window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal })) + ? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character: who })) + : window.confirm(t('confirm', { character: who, pd: pdTotal, vp: vpTotal })) if (!ok) return setSending(true) 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', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ - character, - items: lines.map((l) => ({ id: l.it.id, copies: l.copies })), - provider: activeMethod, - locale, - }), + body: JSON.stringify( + gift + ? { source: character, destination: destination.trim(), security_token: token, items, provider: activeMethod, locale } + : { character, items, provider: activeMethod, locale }, + ), }) const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json() if (data.success && data.url) { @@ -284,7 +346,7 @@ export function StoreBrowser({ return } 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()) } else if (data.error === 'amountTooLow') { setModal({ ok: false, text: t('errors.amountTooLow', { min: eurNumber(data.min ?? 1, locale) }) }) @@ -300,19 +362,74 @@ export function StoreBrowser({ return ( <> -
-
-

{t('choosePlayer')}

-
- -

- PD: {dp} · PV: {vp} -

-
+ {/* Paso 1 del regalo (#char-select-div en el original): de quién a quién y + token. Desaparece al pulsar "Mostrar Regalos" y deja sitio al catálogo. */} + {gift && !shown && ( +
+
+

{t('gift.choosePlayer')}

+
+
+ + + + + + + + + + + + + + + + +
+ setDestination(e.target.value)} + placeholder={t('gift.destPlaceholder')} + /> +
+ setConfirmDest(e.target.value)} + placeholder={t('gift.confirmPlaceholder')} + /> +
+ +
+ +
+ +
+ {formError &&
{formError}
} +
+ )} + + {!gift && ( +
+
+

{t('choosePlayer')}

+
+ +

+ PD: {dp} · PV: {vp} +

+
+ )} {loading &&

{t('loading')}

} - {catalog && !loading && ( + {catalog && !loading && (!gift || shown) && (
    {catalog.map((c) => ( diff --git a/web-next/lib/characters.ts b/web-next/lib/characters.ts index c71a67f..c8084e2 100644 --- a/web-next/lib/characters.ts +++ b/web-next/lib/characters.ts @@ -23,6 +23,30 @@ export async function getGameCharacters(accountId: number): Promise { + const trimmed = name.trim() + if (!trimmed) return null + try { + const [rows] = await db(DB.characters).query( + '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 { const trimmed = name.trim() if (!trimmed) return null diff --git a/web-next/lib/gift-check.ts b/web-next/lib/gift-check.ts new file mode 100644 index 0000000..d800bf1 --- /dev/null +++ b/web-next/lib/gift-check.ts @@ -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 } +} diff --git a/web-next/lib/gift.ts b/web-next/lib/gift.ts deleted file mode 100644 index c8661f4..0000000 --- a/web-next/lib/gift.ts +++ /dev/null @@ -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 { - try { - const [rows] = await db(DB.default).query( - `SELECT c.id AS cat_id, c.name AS cat_name, i.id, i.item_id, i.name, i.price, i.image_url - FROM home_category c - JOIN home_item i ON i.category_id = c.id - ORDER BY c.name, i.name`, - ) - const byCat = new Map() - for (const r of rows) { - let cat = byCat.get(r.cat_id) - if (!cat) { - cat = { id: r.cat_id, name: r.cat_name, items: [] } - byCat.set(r.cat_id, cat) - } - cat.items.push({ id: r.id, itemId: r.item_id, name: r.name, price: Number(r.price), imageUrl: r.image_url }) - } - return [...byCat.values()] - } catch { - return [] - } -} - -export interface CartLine { - id: number - qty: number -} -export interface PricedLine { - itemId: number - name: string - qty: number - unit: number - subtotal: number -} -export interface PricedCart { - lines: PricedLine[] - total: number -} - -/** - * Valida un carrito contra los PRECIOS REALES de la BD y calcula el total. - * Nunca se confía en el precio que envía el cliente. Devuelve null si el - * carrito está vacío, tiene cantidades no válidas o algún ítem no existe. - */ -export async function priceCart(cart: CartLine[]): Promise { - const qtyById = new Map() - for (const l of cart) { - const id = Math.floor(Number(l?.id)) - const qty = Math.floor(Number(l?.qty)) - if (!Number.isInteger(id) || id <= 0) return null - if (!Number.isInteger(qty) || qty < 1 || qty > MAX_QTY) return null - qtyById.set(id, Math.min(MAX_QTY, (qtyById.get(id) ?? 0) + qty)) - } - if (qtyById.size === 0) return null - - const ids = [...qtyById.keys()] - let rows: RowDataPacket[] - try { - ;[rows] = await db(DB.default).query( - `SELECT id, item_id, name, price FROM home_item WHERE id IN (${ids.map(() => '?').join(',')})`, - ids, - ) - } catch { - return null - } - if (rows.length !== ids.length) return null // algún id no existe en el catálogo - - const lines: PricedLine[] = [] - let total = 0 - for (const r of rows) { - const qty = qtyById.get(Number(r.id))! - const unit = Number(r.price) - const subtotal = Math.round(unit * qty * 100) / 100 - total += subtotal - lines.push({ itemId: Number(r.item_id), name: r.name, qty, unit, subtotal }) - } - return { lines, total: Math.round(total * 100) / 100 } -} - -/** - * Entrega el regalo: envía los ítems por correo al personaje destino con SOAP - * (`.send items`, igual que la tienda). Trocea en correos de máximo 12 ítems - * (límite de AzerothCore). `items` = pares { i: item_id, q: cantidad }. - * Requiere el worldserver arriba; devuelve false si algún correo falla. - */ -export async function sendGiftByMail( - destination: string, - sender: string, - items: { i: number; q: number }[], -): Promise { - if (!SAFE_NAME.test(destination)) return false - const clean = (Array.isArray(items) ? items : []) - .map((it) => ({ i: Math.floor(Number(it?.i)), q: Math.floor(Number(it?.q)) })) - .filter((it) => Number.isInteger(it.i) && it.i > 0 && Number.isInteger(it.q) && it.q >= 1 && it.q <= MAX_QTY) - if (clean.length === 0) return false - - const subject = 'Has recibido un regalo' - const fromLabel = SAFE_NAME.test(sender) ? sender : 'un amigo' - const body = `Regalo enviado por ${fromLabel}. Que lo disfrutes.` - - for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) { - const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT) - const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ') - const res = await executeSoapCommand(`.send items "${destination}" "${subject}" "${body}" ${itemsStr}`) - if (res === null) return false - } - return true -} diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts index ea9187e..63690c1 100644 --- a/web-next/lib/paid-services.ts +++ b/web-next/lib/paid-services.ts @@ -1,8 +1,7 @@ import { executeSoapCommand } from './soap' import { creditDPoints } from './dpoints' -import { sendGiftByMail } from './gift' import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild' -import { fulfillStoreOrder } from './store' +import { fulfillStoreOrder, giftMail } from './store' import { checkFactionChangeEligibility } from './change-faction' import { checkTransferEligibility } from './transfer-character' import { @@ -125,21 +124,14 @@ export const PAID_SERVICES: Record = { extraFields: ['destination_account'], precheck: ({ accountId, email, character, body }) => checkTransferEligibility(accountId, email, character, body), }, - // Enviar regalo: al pagar por SumUp, envía por correo los ítems del carrito al - // personaje destino (a un amigo). `sender` = personaje de origen; `items` = JSON - // [{ i: item_id, q: cantidad }] que guardó el checkout en metadata. + // Enviar regalo: es una compra de la tienda cuyo correo va a OTRO personaje y + // dice quién lo manda. El carrito se guarda en home_store_order (order_ref) y + // se entrega igual que la tienda, con su reembolso parcial si falla a medias. + // `sender` = personaje de origen, para redactar el correo. '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) - }, + fulfill: (c, m) => fulfillStoreOrder(c, m.order_ref || '', giftMail(m.sender || '')), extraFields: [], }, // Renombrar hermandad (1000 PD = 10 €): al pagar, aplica `.guild rename`. diff --git a/web-next/lib/store.ts b/web-next/lib/store.ts index 9b97707..80e0dbe 100644 --- a/web-next/lib/store.ts +++ b/web-next/lib/store.ts @@ -14,6 +14,11 @@ import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } f export type Currency = 'pd' | 'pv' 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 export interface StoreItem { @@ -291,6 +296,23 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise { if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' } 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 // devolver el carrito entero regalaría lo ya entregado. const entries = cartEntries(cart) - const sent = await sendStoreItems(character, entries) + const sent = await sendStoreItems(character, entries, mail) if (sent < entries.length) { const left = entries.slice(sent) 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( character: string, ref: string, + mail?: Mail, ): Promise<{ ok: boolean; refundEur?: number }> { if (!ref) return { ok: false } let rows: RowDataPacket[] = [] @@ -457,7 +481,7 @@ export async function fulfillStoreOrder( if (entries.length === 0) return { ok: false } 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 (!priced) { @@ -486,13 +510,17 @@ export async function fulfillStoreOrder( * resto. Cada correo es un `.send items`, así que el troceo es atómico por * correo: un chunk sale entero o no sale. */ -async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise { +async function sendStoreItems( + character: string, + items: { i: number; q: number }[], + mail?: Mail, +): Promise { if (!SAFE_NAME.test(character)) return 0 // 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. if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0 - const subject = 'Tienda' - const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!' + const subject = mail?.subject ?? 'Tienda' + const body = mail?.body ?? 'Has recibido los objetos de la tienda. ¡Que los disfrutes!' let sent = 0 for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) { const chunk = items.slice(n, n + MAIL_ITEM_LIMIT) diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 98fdae1..47bd985 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -763,7 +763,7 @@ "intro": "The Send gift 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.", "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 SumUp.", + "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 here.", "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.", @@ -1965,7 +1965,12 @@ "invalidCharacter": "Invalid character.", "emptyCart": "Your cart is empty.", "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", "newsIntro": "List of additions made based on the community's suggestions in our forum.", @@ -1976,6 +1981,17 @@ "paySumUp": "Card (SumUp)", "minCard": "Minimum {min} €", "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." + } } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 6f2a217..39bb139 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -763,7 +763,7 @@ "intro": "La herramienta Enviar regalo 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.", "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 SumUp.", + "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 aquí.", "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.", @@ -1965,7 +1965,12 @@ "invalidCharacter": "El personaje no es válido.", "emptyCart": "Tu carrito está vacío.", "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", "newsIntro": "Lista de adiciones realizadas en base a las sugerencias de la comunidad en nuestro foro.", @@ -1976,6 +1981,17 @@ "paySumUp": "Tarjeta (SumUp)", "minCard": "Mínimo {min} €", "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." + } } }