feat: bet placement panel with odds and payout preview
BetPanel component with pick-winner buttons, preset amounts (21/100/500/ 1000 sats), Cashu token paste, odds display from server, and payout preview with 5% house cut. Wired into FightCardPage for scheduled fights. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
fd22b126e7
commit
de15d27468
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useWallet } from '../composables/useWallet'
|
||||||
|
import { useNostr } from '../composables/useNostr'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fightId: string
|
||||||
|
botA: { id: string; name: string; eloRating: number; tier: number }
|
||||||
|
botB: { id: string; name: string; eloRating: number; tier: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { isWalletConnected, walletMethod } = useWallet()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const selectedBotId = ref<string | null>(null)
|
||||||
|
const amount = ref(21)
|
||||||
|
const cashuToken = ref('')
|
||||||
|
const isPlacing = ref(false)
|
||||||
|
const betError = ref('')
|
||||||
|
const betSuccess = ref(false)
|
||||||
|
|
||||||
|
// Odds
|
||||||
|
const odds = ref<{ botAOdds: number; botBOdds: number; botAPayoutMultiplier: number; botBPayoutMultiplier: number } | null>(null)
|
||||||
|
|
||||||
|
const AMOUNTS = [21, 100, 500, 1000]
|
||||||
|
|
||||||
|
const selectedOdds = computed(() => {
|
||||||
|
if (!odds.value || !selectedBotId.value) return null
|
||||||
|
return selectedBotId.value === props.botA.id
|
||||||
|
? odds.value.botAPayoutMultiplier
|
||||||
|
: odds.value.botBPayoutMultiplier
|
||||||
|
})
|
||||||
|
|
||||||
|
const payoutPreview = computed(() => {
|
||||||
|
if (!selectedOdds.value) return 0
|
||||||
|
const gross = Math.round(amount.value * selectedOdds.value)
|
||||||
|
const houseCut = Math.round(gross * 0.05)
|
||||||
|
return gross - houseCut
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadOdds() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bets/odds/${props.fightId}`)
|
||||||
|
if (res.ok) odds.value = await res.json()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function placeBet() {
|
||||||
|
if (!selectedBotId.value || !pubkey.value || isPlacing.value) return
|
||||||
|
if (amount.value <= 0) { betError.value = 'Amount must be positive'; return }
|
||||||
|
|
||||||
|
isPlacing.value = true
|
||||||
|
betError.value = ''
|
||||||
|
betSuccess.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
fightId: props.fightId,
|
||||||
|
pubkey: pubkey.value,
|
||||||
|
botId: selectedBotId.value,
|
||||||
|
amountSats: amount.value,
|
||||||
|
}
|
||||||
|
if (cashuToken.value.trim()) {
|
||||||
|
body.cashuToken = cashuToken.value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/bets/place', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
betSuccess.value = true
|
||||||
|
selectedBotId.value = null
|
||||||
|
cashuToken.value = ''
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
betError.value = data.error || `Failed (${res.status})`
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
betError.value = 'Network error placing bet'
|
||||||
|
}
|
||||||
|
isPlacing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOdds)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border border-neon-purple/30 rounded-lg bg-black/80 p-3 sm:p-4">
|
||||||
|
<h3 class="font-display font-black text-xs tracking-widest text-neon-purple mb-3">PLACE YOUR BET</h3>
|
||||||
|
|
||||||
|
<!-- Success -->
|
||||||
|
<div v-if="betSuccess" class="text-center py-4">
|
||||||
|
<p class="font-display text-neon-green text-sm tracking-wider">BET PLACED!</p>
|
||||||
|
<p class="font-mono text-text-muted text-xs mt-1">{{ amount }} sats on the line</p>
|
||||||
|
<button
|
||||||
|
class="mt-3 px-4 py-1.5 border border-neon-purple/40 text-neon-purple font-display text-xs tracking-wider
|
||||||
|
hover:bg-neon-purple/10 transition-all"
|
||||||
|
@click="betSuccess = false"
|
||||||
|
>
|
||||||
|
BET AGAIN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Pick winner -->
|
||||||
|
<p class="font-pixel text-[9px] text-text-muted tracking-wider mb-2">PICK WINNER</p>
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 px-3 rounded-lg border-2 font-display font-bold text-xs tracking-wider transition-all"
|
||||||
|
:class="selectedBotId === botA.id
|
||||||
|
? 'border-neon-cyan bg-neon-cyan/15 text-neon-cyan'
|
||||||
|
: 'border-border text-text-secondary hover:border-neon-cyan/30'"
|
||||||
|
@click="selectedBotId = botA.id"
|
||||||
|
>
|
||||||
|
{{ botA.name }}
|
||||||
|
<span v-if="odds" class="block font-mono text-[9px] text-text-muted mt-0.5">
|
||||||
|
{{ (odds.botAOdds * 100).toFixed(0) }}% favored
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 px-3 rounded-lg border-2 font-display font-bold text-xs tracking-wider transition-all"
|
||||||
|
:class="selectedBotId === botB.id
|
||||||
|
? 'border-neon-pink bg-neon-pink/15 text-neon-pink'
|
||||||
|
: 'border-border text-text-secondary hover:border-neon-pink/30'"
|
||||||
|
@click="selectedBotId = botB.id"
|
||||||
|
>
|
||||||
|
{{ botB.name }}
|
||||||
|
<span v-if="odds" class="block font-mono text-[9px] text-text-muted mt-0.5">
|
||||||
|
{{ (odds.botBOdds * 100).toFixed(0) }}% favored
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Amount -->
|
||||||
|
<p class="font-pixel text-[9px] text-text-muted tracking-wider mb-2">AMOUNT (SATS)</p>
|
||||||
|
<div class="flex gap-1.5 mb-3">
|
||||||
|
<button
|
||||||
|
v-for="a in AMOUNTS"
|
||||||
|
:key="a"
|
||||||
|
class="flex-1 py-1.5 rounded-md border font-mono text-xs transition-all"
|
||||||
|
:class="amount === a
|
||||||
|
? 'border-neon-yellow bg-neon-yellow/15 text-neon-yellow'
|
||||||
|
: 'border-border text-text-muted hover:border-neon-yellow/30'"
|
||||||
|
@click="amount = a"
|
||||||
|
>
|
||||||
|
{{ a }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cashu token paste -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="font-pixel text-[9px] text-text-muted tracking-wider mb-1">CASHU TOKEN (PASTE)</p>
|
||||||
|
<input
|
||||||
|
v-model="cashuToken"
|
||||||
|
type="text"
|
||||||
|
placeholder="cashuA..."
|
||||||
|
class="w-full px-3 py-1.5 bg-surface border border-border rounded-md
|
||||||
|
font-mono text-xs text-text-primary placeholder:text-text-muted
|
||||||
|
focus:border-neon-cyan/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payout preview -->
|
||||||
|
<div v-if="selectedBotId && selectedOdds" class="mb-3 px-3 py-2 bg-neon-green/5 border border-neon-green/20 rounded-md">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-pixel text-[9px] text-text-muted">PAYOUT (minus 5% house)</span>
|
||||||
|
<span class="font-display font-black text-sm text-neon-green">{{ payoutPreview }} SATS</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-0.5">
|
||||||
|
<span class="font-pixel text-[9px] text-text-muted">MULTIPLIER</span>
|
||||||
|
<span class="font-mono text-[10px] text-neon-green">{{ (selectedOdds * 0.95).toFixed(2) }}x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<p v-if="betError" class="text-ko font-mono text-xs mb-2">{{ betError }}</p>
|
||||||
|
|
||||||
|
<!-- Place bet button -->
|
||||||
|
<button
|
||||||
|
:disabled="!selectedBotId || isPlacing || (!cashuToken.trim() && !isWalletConnected)"
|
||||||
|
class="w-full py-2.5 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||||
|
font-display font-black text-xs tracking-widest rounded-lg
|
||||||
|
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
flex items-center justify-center gap-2"
|
||||||
|
@click="placeBet"
|
||||||
|
>
|
||||||
|
<span v-if="isPlacing" class="w-4 h-4 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||||
|
{{ isPlacing ? 'PLACING...' : 'PLACE BET' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="!isWalletConnected && !cashuToken.trim()" class="font-pixel text-[8px] text-text-muted text-center mt-2">
|
||||||
|
Connect wallet or paste Cashu token to bet
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
import PixelGlove from '../components/PixelGlove.vue'
|
import PixelGlove from '../components/PixelGlove.vue'
|
||||||
|
import BetPanel from '../components/BetPanel.vue'
|
||||||
|
|
||||||
interface BotInfo {
|
interface BotInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -15,6 +16,8 @@ interface BotInfo {
|
|||||||
|
|
||||||
interface UpcomingFight {
|
interface UpcomingFight {
|
||||||
id: string
|
id: string
|
||||||
|
botAId: string
|
||||||
|
botBId: string
|
||||||
botA: BotInfo | null
|
botA: BotInfo | null
|
||||||
botB: BotInfo | null
|
botB: BotInfo | null
|
||||||
arenaInfo: { name: string } | null
|
arenaInfo: { name: string } | null
|
||||||
@@ -409,6 +412,15 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bet panel for upcoming fights -->
|
||||||
|
<div v-if="featured && featured.botA && featured.botB && featured.status === 'scheduled'" class="px-4 pb-2 shrink-0">
|
||||||
|
<BetPanel
|
||||||
|
:fight-id="featured.id"
|
||||||
|
:bot-a="{ id: featured.botAId, name: featured.botA.name, eloRating: featured.botA.eloRating, tier: featured.botA.tier }"
|
||||||
|
:bot-b="{ id: featured.botBId, name: featured.botB.name, eloRating: featured.botB.eloRating, tier: featured.botB.tier }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Enter the ring -->
|
<!-- Enter the ring -->
|
||||||
<div class="px-4 pb-3 lg:pb-5 shrink-0">
|
<div class="px-4 pb-3 lg:pb-5 shrink-0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|||||||
Reference in New Issue
Block a user