feat: zap the winner button with lightning animation

Add ZAP WINNER button in FightViewer after fight ends. Server endpoint
POST /api/payments/zap increments zapsReceived on winner bot. Show zap
count on bot profile page. Add zaps_received column with migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 20:10:46 +00:00
co-authored by Claude Opus 4.6
parent d1fe4d6e83
commit d22d739666
6 changed files with 92 additions and 1 deletions
+49
View File
@@ -9,6 +9,7 @@ import {
setMusicIntensity, stopAllAudio,
setMasterMute, isMasterMuted, ensureAudioContext,
speakQuestion, speakAnswer, speakNarration,
prefetchQuestion, prefetchAnswer, prefetchNarration,
sfxRandomComedy, sfxRandomFail, sfxVineBoom, sfxEmotionalDamage,
} from '../game/sounds'
@@ -70,6 +71,28 @@ const glitching = ref(false)
const soundOn = ref(true)
const insanityMode = ref(false)
// Zap the winner
const zapSent = ref(false)
const zapAnimating = ref(false)
async function zapWinner() {
if (!props.fight.winnerId || zapSent.value || zapAnimating.value) return
zapAnimating.value = true
try {
const res = await fetch('/api/payments/zap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
winnerId: props.fight.winnerId,
fightId: props.fight.id,
amountSats: 21,
}),
})
if (res.ok) zapSent.value = true
} catch { /* ignore */ }
setTimeout(() => { zapAnimating.value = false }, 800)
}
// Reactions
const REACTION_EMOJIS: Record<string, string> = {
fist: '\u{1F44A}',
@@ -758,6 +781,20 @@ async function _doReplay() {
>
{{ insanityMode ? 'INSANITY' : 'SKIP' }}
</button>
<!-- Zap winner button -->
<button
v-if="fight.status === 'finished' && fight.winnerId && !isReplaying"
class="px-3 py-1.5 border font-display font-bold text-[10px] tracking-wider transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:class="zapSent
? 'border-neon-yellow/50 text-neon-yellow bg-neon-yellow/10'
: 'border-neon-yellow/30 text-neon-yellow hover:bg-neon-yellow/10 hover:border-neon-yellow/50'"
:disabled="zapSent || zapAnimating"
@click="zapWinner"
>
<span :class="{ 'zap-flash': zapAnimating }">{{ zapSent ? 'ZAPPED!' : 'ZAP WINNER' }}</span>
</button>
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
</div>
@@ -916,6 +953,18 @@ async function _doReplay() {
100% { opacity: 0; transform: translateY(-140px) scale(0.6); }
}
/* Zap lightning flash */
.zap-flash {
animation: zap-bolt 0.8s ease-out;
}
@keyframes zap-bolt {
0% { opacity: 1; filter: brightness(1); }
15% { opacity: 1; filter: brightness(3) drop-shadow(0 0 8px #ffe14d); }
30% { opacity: 0.6; filter: brightness(1.5); }
50% { opacity: 1; filter: brightness(2.5) drop-shadow(0 0 12px #ffe14d); }
100% { opacity: 1; filter: brightness(1); }
}
/* VHS scanline overlay on canvas */
.glitch-container::after {
content: '';
+6 -1
View File
@@ -46,6 +46,7 @@ interface BotStats {
satsWon: number
satsWagered: number
hasWallet: boolean
zapsReceived: number
createdAt: string
// Owner-only webhook fields
webhookUrl?: string
@@ -487,7 +488,7 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<!-- Sats stats -->
<div v-if="stats.satsWon || stats.satsWagered" class="flex gap-2 mt-2">
<div v-if="stats.satsWon || stats.satsWagered || stats.zapsReceived" 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>
@@ -496,6 +497,10 @@ const tierClass = (t: number) => `tier-${t}`
<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 v-if="stats.zapsReceived" class="flex-1 border border-neon-yellow/20 bg-neon-yellow/5 p-2.5 text-center">
<p class="font-display font-bold text-base text-neon-yellow">{{ stats.zapsReceived }}</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">ZAPS</p>
</div>
</div>
<!-- Wallet connection (owner only) -->
+1
View File
@@ -23,6 +23,7 @@ export const bots = sqliteTable('bots', {
satsWon: integer('sats_won').notNull().default(0),
satsWagered: integer('sats_wagered').notNull().default(0),
hasWallet: integer('has_wallet', { mode: 'boolean' }).notNull().default(false),
zapsReceived: integer('zaps_received').notNull().default(0),
botType: text('bot_type', { enum: ['regular', 'mock', 'classic'] }).notNull().default('regular'),
createdAt: text('created_at').notNull(),
})
+2
View File
@@ -102,6 +102,8 @@ export function runMigrations() {
"ALTER TABLE bots ADD COLUMN sats_won INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN sats_wagered INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
// Zap tracking
"ALTER TABLE bots ADD COLUMN zaps_received INTEGER NOT NULL DEFAULT 0",
// Classic bots
"ALTER TABLE bots ADD COLUMN bot_type TEXT NOT NULL DEFAULT 'regular'",
]
+1
View File
@@ -173,6 +173,7 @@ botsRouter.get('/:name/stats', async (c) => {
publicKey: schema.bots.publicKey,
webhookUrl: schema.bots.webhookUrl,
consecutiveErrors: schema.bots.consecutiveErrors,
zapsReceived: schema.bots.zapsReceived,
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
if (botRows.length === 0) {
+33
View File
@@ -271,3 +271,36 @@ paymentsRouter.delete('/disconnect-wallet', async (c) => {
return c.json({ success: true })
})
// POST /zap — zap sats to a fight winner
paymentsRouter.post('/zap', rateLimit(60_000, 10), async (c) => {
const { winnerId, fightId, amountSats } = await c.req.json<{
winnerId: string
fightId: string
amountSats: number
}>()
if (!winnerId || !fightId) return c.json({ error: 'Missing winnerId or fightId' }, 400)
const amount = amountSats || 21
// Verify the fight exists and this bot actually won
const fightRows = await db.select({
winnerId: schema.fights.winnerId,
status: schema.fights.status,
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
if (fightRows.length === 0) return c.json({ error: 'Fight not found' }, 404)
if (fightRows[0].status !== 'finished') return c.json({ error: 'Fight not finished' }, 400)
if (fightRows[0].winnerId !== winnerId) return c.json({ error: 'Bot did not win this fight' }, 400)
// Increment zaps on the winner
const botRows = await db.select({ zapsReceived: schema.bots.zapsReceived })
.from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1)
if (botRows.length === 0) return c.json({ error: 'Bot not found' }, 404)
await db.update(schema.bots).set({
zapsReceived: (botRows[0].zapsReceived || 0) + 1,
}).where(eq(schema.bots.id, winnerId))
return c.json({ ok: true, zapsReceived: (botRows[0].zapsReceived || 0) + 1 })
})