f0b006404c
Selector de forma de pago en el carrito (Saldo PD/PV · Stripe · SumUp), como el resto de servicios. Con tarjeta, el carrito se convierte a euros (100 PD = 1 €, 200 PV = 1 €), se guarda como pedido y se entrega tras confirmarse el pago. - BD: tabla home_store_order (ref, cuenta, personaje, items, importe) para no depender del límite de metadata de la pasarela (sql/home_store_order.sql). - lib/store: storeEuroTotal, createStoreOrder, fulfillStoreOrder. - paid-services: servicio 'store' (fulfill = enviar el pedido por order_ref); la entrega la dispara service-success/reconciliación/webhook como los demás. - /api/store/send: rama provider stripe/sumup (crea pedido + checkout, mínimo 1 €/0,50 €) además del pago con saldo. - StoreBrowser: selector Saldo/Stripe/SumUp; con tarjeta redirige a la pasarela. - i18n Store (payMethod/payBalance/payStripe/paySumUp/confirmCard/amountTooLow) y Paid.store (title/success). Verificado: euros 200PD+28PV=2,14 €, pedido y fulfill por order_ref (SOAP real pendiente, worldserver caído). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useTranslations, useLocale } from 'next-intl'
|
|
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
|
import { WowheadLink } from '@/components/WowheadLink'
|
|
import { wowheadIcon } from '@/lib/wowhead'
|
|
import type { StoreCategory, StoreItem } from '@/lib/store'
|
|
|
|
const ICON_FALLBACK = 'inv_misc_questionmark'
|
|
|
|
/** Nodo de categoría colapsable; renderiza sus ítems solo cuando está abierto. */
|
|
function CategoryNode({
|
|
cat,
|
|
depth,
|
|
cart,
|
|
onAdd,
|
|
}: {
|
|
cat: StoreCategory
|
|
depth: number
|
|
cart: Map<number, StoreItem>
|
|
onAdd: (it: StoreItem) => void
|
|
}) {
|
|
const t = useTranslations('Store')
|
|
const [open, setOpen] = useState(false)
|
|
const spanClass = depth === 0 ? 'store-cat' : depth === 1 ? 'store-subcat' : 'store-sub-subcat'
|
|
return (
|
|
<li>
|
|
<span className={`${spanClass} first-brown`} role="button" tabIndex={0} onClick={() => setOpen((o) => !o)}>
|
|
<i className={`fas fa-angle-right green-info${open ? ' fa-rotate-90' : ''}`} /> {cat.name}
|
|
</span>
|
|
{open && (
|
|
<>
|
|
{cat.children.length > 0 && (
|
|
<ul className={depth === 0 ? 'store-subcat' : 'store-sub-subcat'}>
|
|
{cat.children.map((c) => (
|
|
<CategoryNode key={c.code} cat={c} depth={depth + 1} cart={cart} onAdd={onAdd} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
{cat.items.length > 0 && (
|
|
<div className="item-list">
|
|
{cat.items.map((it) => {
|
|
const added = cart.has(it.id)
|
|
return (
|
|
<div className="item-box" key={it.id}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img className="item-img-box" src={wowheadIcon(it.icon || ICON_FALLBACK, 'large')} alt={it.name} loading="lazy" />
|
|
<p className="item-name centered">
|
|
<WowheadLink id={it.itemId}>{it.name}</WowheadLink>
|
|
</p>
|
|
<hr />
|
|
<p>
|
|
<span className="second-brown">{t('quantity')}:</span> <span>{it.qty}</span>
|
|
</p>
|
|
<p>
|
|
<span className={it.currency === 'pv' ? 'vp-color' : 'dp-color'}>{it.currency === 'pv' ? 'PV' : 'PD'}:</span>{' '}
|
|
<span>{it.price}</span>
|
|
</p>
|
|
<br />
|
|
<button
|
|
type="button"
|
|
className="store-add-button"
|
|
onClick={() => onAdd(it)}
|
|
disabled={added}
|
|
style={added ? { color: '#d79602' } : undefined}
|
|
>
|
|
{added ? t('added') : t('add')}
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
type PayMethod = 'balance' | 'stripe' | 'sumup'
|
|
|
|
export function StoreBrowser({ characters, dp, vp }: { characters: CharOption[]; dp: number; vp: number }) {
|
|
const t = useTranslations('Store')
|
|
const locale = useLocale()
|
|
const [character, setCharacter] = useState('')
|
|
const [catalog, setCatalog] = useState<StoreCategory[] | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [cart, setCart] = useState<Map<number, StoreItem>>(new Map())
|
|
const [method, setMethod] = useState<PayMethod>('balance')
|
|
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
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/store/catalog', { credentials: 'same-origin' })
|
|
const data: { success?: boolean; catalog?: StoreCategory[] } = await res.json()
|
|
setCatalog(data.success ? data.catalog ?? [] : [])
|
|
} catch {
|
|
setCatalog([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
function addItem(it: StoreItem) {
|
|
setCart((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(it.id, it)
|
|
return next
|
|
})
|
|
}
|
|
function removeItem(id: number) {
|
|
setCart((prev) => {
|
|
const next = new Map(prev)
|
|
next.delete(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const lines = [...cart.values()]
|
|
const pdTotal = lines.filter((l) => l.currency === 'pd').reduce((s, l) => s + l.price, 0)
|
|
const vpTotal = lines.filter((l) => l.currency === 'pv').reduce((s, l) => s + l.price, 0)
|
|
// 100 PD = 1 €, 200 PV = 1 € (igual que en lib/store storeEuroTotal).
|
|
const eurTotal = Math.round((pdTotal / 100 + vpTotal / 200) * 100) / 100
|
|
|
|
async function send() {
|
|
if (sending || cart.size === 0) return
|
|
const card = method === 'stripe' || method === 'sumup'
|
|
const ok = card
|
|
? window.confirm(t('confirmCard', { eur: eurTotal, character }))
|
|
: window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))
|
|
if (!ok) return
|
|
setSending(true)
|
|
try {
|
|
const res = await fetch('/api/store/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ character, items: [...cart.keys()], provider: method, locale }),
|
|
})
|
|
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
|
|
if (data.success && data.url) {
|
|
window.location.href = data.url // pasarela (Stripe/SumUp)
|
|
return
|
|
}
|
|
if (data.success) {
|
|
setModal({ ok: true, text: t('successMsg', { character }) })
|
|
setCart(new Map())
|
|
} else if (data.error === 'amountTooLow') {
|
|
setModal({ ok: false, text: t('errors.amountTooLow', { min: data.min ?? 1 }) })
|
|
} else {
|
|
setModal({ ok: false, text: t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic') })
|
|
}
|
|
} catch {
|
|
setModal({ ok: false, text: t('errors.generic') })
|
|
} finally {
|
|
setSending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<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>}
|
|
|
|
{catalog && !loading && (
|
|
<div className="box-content" id="store-div">
|
|
<ul id="store-list">
|
|
{catalog.map((c) => (
|
|
<CategoryNode key={c.code} cat={c} depth={0} cart={cart} onAdd={addItem} />
|
|
))}
|
|
</ul>
|
|
|
|
{/* Carrito */}
|
|
<div id="cart-list">
|
|
{lines.length > 0 && (
|
|
<div className="pay-method-select">
|
|
<p className="second-brown">{t('payMethod')}</p>
|
|
<div className="pay-method-options">
|
|
{([
|
|
{ id: 'balance' as PayMethod, label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` },
|
|
{ id: 'stripe' as PayMethod, label: t('payStripe'), sub: `${eurTotal} €` },
|
|
{ id: 'sumup' as PayMethod, label: t('paySumUp'), sub: `${eurTotal} €` },
|
|
]).map((o) => (
|
|
<label key={o.id} className={`pay-method-option${method === o.id ? ' selected' : ''}`}>
|
|
<input type="radio" name="store-pay" value={o.id} checked={method === o.id} onChange={() => setMethod(o.id)} />
|
|
<span className="pay-method-label">{o.label}</span>
|
|
<span className="pay-method-sub yellow-info">{o.sub}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<table className="max-left-table2">
|
|
<tbody>
|
|
<tr>
|
|
<th className="width-50-td justified">{t('colItem')}</th>
|
|
<th className="justified">{t('colQty')}</th>
|
|
<th className="justified">{t('colValue')}</th>
|
|
<th className="icon-td">
|
|
<button type="button" className="store-empty-button" title={t('empty')} onClick={() => setCart(new Map())} disabled={cart.size === 0}>
|
|
<i className="fas fa-trash" />
|
|
</button>
|
|
</th>
|
|
</tr>
|
|
{lines.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={4} className="second-brown centered">{t('cartEmpty')}</td>
|
|
</tr>
|
|
) : (
|
|
lines.map((l) => (
|
|
<tr key={l.id}>
|
|
<td>
|
|
<WowheadLink id={l.itemId}>{l.name}</WowheadLink>
|
|
</td>
|
|
<td><span>{l.qty}</span></td>
|
|
<td>
|
|
<span className={l.currency === 'pv' ? 'vp-color' : 'dp-color'}>{l.currency === 'pv' ? 'PV' : 'PD'}:</span> <span>{l.price}</span>
|
|
</td>
|
|
<td className="icon-td">
|
|
<button type="button" className="store-remove-button" onClick={() => removeItem(l.id)} title={t('remove')}>
|
|
<i className="fas fa-trash red-info" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
<tr><td colSpan={4}><hr /></td></tr>
|
|
<tr>
|
|
<td></td>
|
|
<td><span>{lines.length}</span></td>
|
|
<td>
|
|
<span className="dp-color">PD: </span><span>{pdTotal}</span><br />
|
|
<span className="vp-color">PV: </span><span>{vpTotal}</span>
|
|
</td>
|
|
<td>
|
|
<button type="button" className="store-send-button" onClick={send} disabled={sending || cart.size === 0}>
|
|
{sending ? t('sending') : t('send')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{modal && (
|
|
<div className="modal-div" onClick={() => setModal(null)}>
|
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
<span className="modal-close" role="button" tabIndex={0} onClick={() => setModal(null)}>×</span>
|
|
<p className={modal.ok ? 'ok-form-response' : 'red-form-response'}>{modal.text}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|