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 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
|
||||
|
||||
Reference in New Issue
Block a user