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:
Dorian
2026-03-08 19:55:44 +00:00
co-authored by Claude Opus 4.6
parent fd22b126e7
commit de15d27468
2 changed files with 213 additions and 0 deletions
+201
View File
@@ -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>
+12
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
import PixelGlove from '../components/PixelGlove.vue'
import BetPanel from '../components/BetPanel.vue'
interface BotInfo {
name: string
@@ -15,6 +16,8 @@ interface BotInfo {
interface UpcomingFight {
id: string
botAId: string
botBId: string
botA: BotInfo | null
botB: BotInfo | null
arenaInfo: { name: string } | null
@@ -409,6 +412,15 @@ onUnmounted(() => {
</button>
</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 -->
<div class="px-4 pb-3 lg:pb-5 shrink-0">
<RouterLink