From d22d739666789efd23f746bec1d511a53728b8c3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 20:10:46 +0000 Subject: [PATCH] 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 --- frontend/src/components/FightViewer.vue | 49 +++++++++++++++++++++++++ frontend/src/pages/BotProfilePage.vue | 7 +++- server/src/db/schema.ts | 1 + server/src/db/startup.ts | 2 + server/src/routes/bots.ts | 1 + server/src/routes/payments.ts | 33 +++++++++++++++++ 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 0d9cf25..0123b4b 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -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 = { fist: '\u{1F44A}', @@ -758,6 +781,20 @@ async function _doReplay() { > {{ insanityMode ? 'INSANITY' : 'SKIP' }} + + + {{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }} @@ -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: ''; diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index 5d34168..17fb8ed 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -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}` -
+

{{ stats.satsWon || 0 }}

SATS WON

@@ -496,6 +497,10 @@ const tierClass = (t: number) => `tier-${t}`

{{ stats.satsWagered || 0 }}

WAGERED

+
+

{{ stats.zapsReceived }}

+

ZAPS

+
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7fea037..96b4e8c 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -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(), }) diff --git a/server/src/db/startup.ts b/server/src/db/startup.ts index e666d48..3cc7d92 100644 --- a/server/src/db/startup.ts +++ b/server/src/db/startup.ts @@ -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'", ] diff --git a/server/src/routes/bots.ts b/server/src/routes/bots.ts index 39fbc77..ca9f44f 100644 --- a/server/src/routes/bots.ts +++ b/server/src/routes/bots.ts @@ -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) { diff --git a/server/src/routes/payments.ts b/server/src/routes/payments.ts index b212767..83157b9 100644 --- a/server/src/routes/payments.ts +++ b/server/src/routes/payments.ts @@ -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 }) +})