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:
co-authored by
Claude Opus 4.6
parent
d1fe4d6e83
commit
d22d739666
@@ -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: '';
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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'",
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user