feat: wallet UI — WalletConnect component + ranked fight flow in pages
Add WalletConnect.vue with NWC/LN Address connection states. Add "FIGHT FOR SATS" button to JoinBoutPage with entry fee payment flow. Show ranked pot and winner payout in FightPage. Add sats stats and wallet section to BotProfilePage. Extend BotData interface with satsWon/satsWagered/hasWallet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
131d7e587e
commit
95b53e271f
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useWallet } from '../composables/useWallet'
|
||||
|
||||
const { isWalletConnected, walletMethod, paymentStatus, disconnectWallet, connectNWC, connectLightningAddress } = useWallet()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const nwcInput = ref('')
|
||||
const lnAddressInput = ref('')
|
||||
const connectError = ref('')
|
||||
const isConnecting = ref(false)
|
||||
|
||||
async function handleConnectNWC() {
|
||||
if (!nwcInput.value.trim()) return
|
||||
isConnecting.value = true
|
||||
connectError.value = ''
|
||||
try {
|
||||
await connectNWC(nwcInput.value.trim())
|
||||
isExpanded.value = false
|
||||
nwcInput.value = ''
|
||||
} catch (err) {
|
||||
connectError.value = err instanceof Error ? err.message : 'Connection failed'
|
||||
}
|
||||
isConnecting.value = false
|
||||
}
|
||||
|
||||
async function handleConnectLnAddress() {
|
||||
if (!lnAddressInput.value.trim()) return
|
||||
isConnecting.value = true
|
||||
connectError.value = ''
|
||||
try {
|
||||
await connectLightningAddress(lnAddressInput.value.trim())
|
||||
isExpanded.value = false
|
||||
lnAddressInput.value = ''
|
||||
} catch (err) {
|
||||
connectError.value = err instanceof Error ? err.message : 'Connection failed'
|
||||
}
|
||||
isConnecting.value = false
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
await disconnectWallet()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Paying state -->
|
||||
<div v-if="paymentStatus === 'paying' || paymentStatus === 'invoiced'" class="text-center py-3">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
|
||||
<span class="font-display font-bold text-xs tracking-wider text-neon-cyan">PAYING 21 SATS...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmed flash -->
|
||||
<div v-else-if="paymentStatus === 'confirmed'" class="text-center py-3">
|
||||
<span class="font-display font-bold text-xs tracking-wider text-green-400 animate-pulse">LOCKED IN</span>
|
||||
</div>
|
||||
|
||||
<!-- Connected state -->
|
||||
<div v-else-if="isWalletConnected" class="flex items-center justify-center gap-2 py-2">
|
||||
<span class="text-neon-cyan">⚡</span>
|
||||
<span class="font-display font-bold text-[10px] tracking-wider text-neon-cyan">WALLET READY</span>
|
||||
<button
|
||||
class="font-mono text-[9px] text-text-muted hover:text-ko transition-colors ml-2 underline"
|
||||
@click="handleDisconnect"
|
||||
>
|
||||
disconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Not connected -->
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-if="!isExpanded"
|
||||
class="w-full py-2 border border-neon-cyan/30 text-neon-cyan
|
||||
font-display font-bold text-[10px] tracking-wider
|
||||
hover:bg-neon-cyan/10 transition-all"
|
||||
@click="isExpanded = true"
|
||||
>
|
||||
⚡ CONNECT WALLET
|
||||
</button>
|
||||
|
||||
<div v-else class="border border-border p-3 space-y-3">
|
||||
<p class="font-display font-bold text-[10px] tracking-wider text-text-secondary text-center">CONNECT WALLET</p>
|
||||
|
||||
<!-- NWC input -->
|
||||
<div>
|
||||
<label class="font-mono text-[9px] text-text-muted block mb-1">NWC CONNECTION STRING</label>
|
||||
<input
|
||||
v-model="nwcInput"
|
||||
type="text"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
class="w-full bg-surface border border-border px-2 py-1.5 font-mono text-[10px] text-text-primary
|
||||
focus:border-neon-cyan/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
class="w-full mt-1 py-1.5 bg-neon-cyan/10 border border-neon-cyan/30 text-neon-cyan
|
||||
font-display font-bold text-[9px] tracking-wider
|
||||
hover:bg-neon-cyan/20 transition-all disabled:opacity-50"
|
||||
:disabled="!nwcInput.trim() || isConnecting"
|
||||
@click="handleConnectNWC"
|
||||
>
|
||||
{{ isConnecting ? 'CONNECTING...' : 'CONNECT NWC' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 border-t border-border" />
|
||||
<span class="font-mono text-[8px] text-text-muted">OR</span>
|
||||
<div class="flex-1 border-t border-border" />
|
||||
</div>
|
||||
|
||||
<!-- Lightning Address input -->
|
||||
<div>
|
||||
<label class="font-mono text-[9px] text-text-muted block mb-1">LIGHTNING ADDRESS (payouts only)</label>
|
||||
<input
|
||||
v-model="lnAddressInput"
|
||||
type="text"
|
||||
placeholder="you@getalby.com"
|
||||
class="w-full bg-surface border border-border px-2 py-1.5 font-mono text-[10px] text-text-primary
|
||||
focus:border-neon-cyan/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
class="w-full mt-1 py-1.5 bg-neon-purple/10 border border-neon-purple/30 text-neon-purple
|
||||
font-display font-bold text-[9px] tracking-wider
|
||||
hover:bg-neon-purple/20 transition-all disabled:opacity-50"
|
||||
:disabled="!lnAddressInput.trim() || isConnecting"
|
||||
@click="handleConnectLnAddress"
|
||||
>
|
||||
{{ isConnecting ? 'CONNECTING...' : 'SET ADDRESS' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="connectError" class="text-center">
|
||||
<p class="font-mono text-[9px] text-ko">{{ connectError }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full py-1 font-mono text-[9px] text-text-muted hover:text-text-secondary transition-colors"
|
||||
@click="isExpanded = false"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,6 +23,9 @@ interface BotData {
|
||||
winStreak: number
|
||||
bestStreak: number
|
||||
tier: number
|
||||
satsWon: number
|
||||
satsWagered: number
|
||||
hasWallet: boolean
|
||||
}
|
||||
|
||||
interface NostrWindow {
|
||||
@@ -140,6 +143,9 @@ export function useNostr() {
|
||||
winStreak: 0,
|
||||
bestStreak: 0,
|
||||
tier: 0,
|
||||
satsWon: 0,
|
||||
satsWagered: 0,
|
||||
hasWallet: false,
|
||||
}
|
||||
store('bf_bot', bot.value)
|
||||
|
||||
@@ -201,6 +207,9 @@ export function useNostr() {
|
||||
winStreak: 0,
|
||||
bestStreak: 0,
|
||||
tier: 0,
|
||||
satsWon: 0,
|
||||
satsWagered: 0,
|
||||
hasWallet: false,
|
||||
}
|
||||
store('bf_bot', bot.value)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
import WalletConnect from '../components/WalletConnect.vue'
|
||||
import type { SpriteCustomization } from '../game/sprites'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -39,6 +40,9 @@ interface BotStats {
|
||||
totalFights: number
|
||||
rank: number
|
||||
totalBots: number
|
||||
satsWon: number
|
||||
satsWagered: number
|
||||
hasWallet: boolean
|
||||
createdAt: string
|
||||
recentFights: {
|
||||
id: string
|
||||
@@ -421,6 +425,23 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sats stats -->
|
||||
<div v-if="stats.satsWon || stats.satsWagered" class="flex gap-2 mt-2">
|
||||
<div class="flex-1 border border-neon-cyan/20 bg-neon-cyan/5 p-2.5 text-center">
|
||||
<p class="font-display font-bold text-base text-neon-cyan">{{ stats.satsWon || 0 }}</p>
|
||||
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">SATS WON</p>
|
||||
</div>
|
||||
<div class="flex-1 border border-neon-purple/20 bg-neon-purple/5 p-2.5 text-center">
|
||||
<p class="font-display font-bold text-base text-neon-purple">{{ stats.satsWagered || 0 }}</p>
|
||||
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">WAGERED</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet connection (owner only) -->
|
||||
<div v-if="isOwner" class="mt-3">
|
||||
<WalletConnect />
|
||||
</div>
|
||||
|
||||
<!-- Fight actions -->
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
|
||||
@@ -579,6 +579,9 @@ async function handleFightEnd(data: any) {
|
||||
sfxApplause()
|
||||
sfxCrowdCheer()
|
||||
await showLiveOverlay(`${data.winnerName} WINS!`, '#00f0ff', 2000)
|
||||
if (data.mode === 'ranked' && data.potSats) {
|
||||
await showLiveOverlay(`WON ${data.potSats} SATS!`, '#00f0ff', 1500)
|
||||
}
|
||||
liveScene.stopMusic()
|
||||
}
|
||||
|
||||
@@ -592,6 +595,11 @@ async function handleFightEnd(data: any) {
|
||||
{ type: 'divider', round: 0, text: '', color: '' },
|
||||
{ type: 'system', round: 0, text: myWon ? 'YOU WIN!' : 'YOU LOSE!', color: myWon ? 'neon-cyan' : 'neon-pink' },
|
||||
)
|
||||
if (data.mode === 'ranked' && data.potSats && myWon) {
|
||||
liveLogItems.value.push(
|
||||
{ type: 'system', round: 0, text: `YOU WON ${data.potSats} SATS!`, color: 'neon-cyan' },
|
||||
)
|
||||
}
|
||||
scrollLiveLog()
|
||||
disconnectSSE()
|
||||
stopHumanPolling()
|
||||
@@ -949,7 +957,10 @@ function stopAutoBattle() {
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-0.5">
|
||||
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
|
||||
<span class="font-pixel text-[9px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
|
||||
<span class="font-pixel text-[9px] text-text-muted">
|
||||
{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}
|
||||
<span v-if="liveFightData.mode === 'ranked'" class="text-neon-cyan"> | ⚡{{ liveFightData.potSats || 42 }} SATS</span>
|
||||
</span>
|
||||
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import { useWallet } from '../composables/useWallet'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
import WalletConnect from '../components/WalletConnect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
|
||||
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
|
||||
|
||||
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
|
||||
// 'pick-human-avatar' | 'name-human' | 'human-guide' | 'ready'
|
||||
@@ -15,6 +18,7 @@ const isHumanMode = ref(false)
|
||||
const selectedHumanSeed = ref('baby_fighter_1')
|
||||
const error = ref('')
|
||||
const isJoining = ref(false)
|
||||
const isJoiningRanked = ref(false)
|
||||
const queueCount = ref(0)
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -243,6 +247,30 @@ async function fight() {
|
||||
isJoining.value = false
|
||||
}
|
||||
|
||||
async function fightRanked() {
|
||||
if (!bot.value || isJoiningRanked.value) return
|
||||
isJoiningRanked.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const paymentId = await payEntryFee(bot.value.id)
|
||||
const res = await fetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/arena/${data.fightId}`)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
error.value = data.error || 'Ranked match failed.'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Ranked fight error.'
|
||||
}
|
||||
isJoiningRanked.value = false
|
||||
}
|
||||
|
||||
function handleSignOut() {
|
||||
logout()
|
||||
step.value = 'login'
|
||||
@@ -787,6 +815,27 @@ function handleSignOut() {
|
||||
{{ (isHumanMode || bot.isHuman) ? 'Type your answers live against an AI' : 'Queue up against a real AI bot' }}
|
||||
</p>
|
||||
|
||||
<!-- Ranked fight button -->
|
||||
<button
|
||||
v-if="!isHumanMode && !bot.isHuman"
|
||||
class="w-full py-4 bg-neon-cyan/10 border-2 border-neon-cyan text-neon-cyan
|
||||
font-display font-black text-xl tracking-[0.2em]
|
||||
hover:bg-neon-cyan/20 transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex flex-col items-center justify-center gap-1"
|
||||
:disabled="!isWalletConnected || isJoiningRanked"
|
||||
@click="fightRanked"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span v-if="isJoiningRanked" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
|
||||
{{ isJoiningRanked ? 'PAYING...' : '⚡ FIGHT FOR SATS' }}
|
||||
</span>
|
||||
<span class="text-[9px] font-mono tracking-normal font-normal text-neon-cyan/70">21 SATS — WINNER TAKES ALL</span>
|
||||
</button>
|
||||
|
||||
<!-- Wallet connect (shown if no wallet) -->
|
||||
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
|
||||
Reference in New Issue
Block a user