diff --git a/backend/src/index.js b/backend/src/index.js index a613dca4..554d4833 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -114,6 +114,10 @@ import { createSep10Routes, createRequireWalletAuth } from './routes/sep10.js'; import { createZkInputsRoutes } from './routes/zkInputs.js'; import { createSocialTaskRoutes } from './routes/socialTasks.js'; import { createTwitterVerificationService } from './services/twitterVerificationService.js'; +import { createDiscordBotService } from './services/discordBotService.js'; +import { createDiscordBotRoutes } from './routes/discordBot.js'; +import { createTelegramBotService } from './services/telegramBotService.js'; +import { createTelegramBotRoutes } from './routes/telegramBot.js'; import { createNotificationRoutes, createNotificationPreferencesRoutes, @@ -3569,6 +3573,31 @@ export async function createApp(options = {}) { }), }); app.use(`${prefix}/social-tasks`, rateLimiter, ...guard, socialTaskRouter); + + // #1349 — Discord bot slash-command `/trivela-stats` + const discordBotRouter = createDiscordBotRoutes({ + discordBotService: createDiscordBotService({ campaignRepository }), + }); + app.use(`${prefix}/discord`, rateLimiter, discordBotRouter); + + // #1350 — Telegram bot `/claim` command + const telegramBotRouter = createTelegramBotRoutes({ + telegramBotService: createTelegramBotService({ + claimableBalanceRepository: { + listByRecipient: (identity) => { + try { + return dal.db + .prepare('SELECT * FROM claimable_balances WHERE recipient = ?') + .all(identity); + } catch { + return []; + } + }, + }, + identityResolver: (id) => id, + }), + }); + app.use(`${prefix}/telegram`, rateLimiter, telegramBotRouter); } // #551 — SEP-1 stellar.toml (public, no auth, correct content-type + CORS) diff --git a/backend/src/routes/discordBot.js b/backend/src/routes/discordBot.js new file mode 100644 index 00000000..0df7de69 --- /dev/null +++ b/backend/src/routes/discordBot.js @@ -0,0 +1,46 @@ +/** + * Discord bot webhook routes (#1349). + * + * POST /discord/interactions — entry point for Discord slash-commands. + * Currently handles `/trivela-stats`, returning real-time campaign metrics. + */ + +import { Router } from 'express'; + +/** + * @param {{ + * discordBotService: ReturnType, + * verifyDiscordSignature?: import('express').RequestHandler, + * }} deps + * @returns {import('express').Router} + */ +export function createDiscordBotRoutes({ discordBotService, verifyDiscordSignature } = {}) { + const router = Router(); + + // Discord requires a PING interaction to be answered with PONG type 1. + // Subsequent commands arrive as type 2 (APPLICATION_COMMAND). + router.post( + '/interactions', + ...(verifyDiscordSignature ? [verifyDiscordSignature] : []), + (req, res) => { + const body = req.body ?? {}; + + if (body.type === 1) { + return res.json({ type: 1 }); + } + + const commandName = body.data?.name ?? body.command ?? ''; + + if (commandName === 'trivela-stats') { + return res.json(discordBotService.buildStatsResponse()); + } + + return res.json({ + type: 4, + data: { content: `Unknown command: \`/${commandName}\`. Try \`/trivela-stats\`.` }, + }); + }, + ); + + return router; +} diff --git a/backend/src/routes/telegramBot.js b/backend/src/routes/telegramBot.js new file mode 100644 index 00000000..6636c3cc --- /dev/null +++ b/backend/src/routes/telegramBot.js @@ -0,0 +1,32 @@ +/** + * Telegram bot webhook routes (#1350). + * + * POST /telegram/webhook — entry point for Telegram bot updates. + * Currently handles `/claim`, returning claimable reward balances. + */ + +import { Router } from 'express'; + +/** + * @param {{ + * telegramBotService: ReturnType, + * verifyTelegramSecret?: import('express').RequestHandler, + * }} deps + * @returns {import('express').Router} + */ +export function createTelegramBotRoutes({ telegramBotService, verifyTelegramSecret } = {}) { + const router = Router(); + + router.post('/webhook', ...(verifyTelegramSecret ? [verifyTelegramSecret] : []), (req, res) => { + const reply = telegramBotService.handleUpdate(req.body ?? {}); + + if (!reply) { + return res.status(400).json({ error: 'invalid update payload', code: 'VALIDATION_ERROR' }); + } + + // Telegram expects 200 even for replies we send via the Bot API. + return res.json(reply); + }); + + return router; +} diff --git a/backend/src/services/discordBotService.js b/backend/src/services/discordBotService.js new file mode 100644 index 00000000..2ffc0d37 --- /dev/null +++ b/backend/src/services/discordBotService.js @@ -0,0 +1,91 @@ +/** + * Discord bot service for `/trivela-stats` (#1349). + * + * Builds a real-time campaign metrics summary suitable for a Discord + * slash-command response. Data is read from the campaign repository so + * results stay consistent with the REST API. + */ + +/** + * @typedef {{ active: number, total: number, participants: number, rewardPool: number }} CampaignMetrics + */ + +/** + * @param {{ + * campaignRepository: { + * list?: (opts?: object) => Array | { data: Array }, + * getAll?: () => Array, + * }, + * rewardPoolResolver?: (campaign: any) => number, + * }} deps + */ +export function createDiscordBotService({ + campaignRepository, + rewardPoolResolver = defaultRewardPool, +} = {}) { + /** + * Return aggregate campaign metrics for the `/trivela-stats` command. + * @returns {CampaignMetrics} + */ + function getTrivelaStats() { + const campaigns = normalizeList( + campaignRepository?.list?.() ?? campaignRepository?.getAll?.() ?? [], + ); + + let active = 0; + let participants = 0; + let rewardPool = 0; + + for (const campaign of campaigns) { + const isActive = isCampaignActive(campaign); + if (isActive) active += 1; + participants += Number(campaign.participants ?? campaign.participantCount ?? 0); + rewardPool += rewardPoolResolver(campaign); + } + + return { + active, + total: campaigns.length, + participants, + rewardPool: Math.round(rewardPool * 100) / 100, + }; + } + + /** + * Format metrics as a Discord interaction response payload. + * @returns {{ type: 4, data: { content: string } }} + */ + function buildStatsResponse() { + const stats = getTrivelaStats(); + const content = [ + '**Trivela Campaign Stats**', + `Active campaigns: **${stats.active}**`, + `Total campaigns: **${stats.total}**`, + `Total participants: **${stats.participants.toLocaleString()}**`, + `Reward pool: **${stats.rewardPool.toLocaleString()}**`, + ].join('\n'); + + return { type: 4, data: { content } }; + } + + return { getTrivelaStats, buildStatsResponse }; +} + +function isCampaignActive(campaign) { + const status = String(campaign.status ?? '').toLowerCase(); + if (status) return status === 'active' || status === 'published'; + const end = campaign.endDate ?? campaign.end_date; + return end ? new Date(end).getTime() > Date.now() : false; +} + +function defaultRewardPool(campaign) { + const per = Number(campaign.rewardPerAction ?? campaign.reward_per_action ?? 0); + const max = Number(campaign.maxParticipants ?? campaign.max_participants ?? 0); + return per * max; +} + +function normalizeList(list) { + if (Array.isArray(list)) return list; + if (Array.isArray(list?.data)) return list.data; + return []; +} diff --git a/backend/src/services/discordBotService.test.js b/backend/src/services/discordBotService.test.js new file mode 100644 index 00000000..05d12104 --- /dev/null +++ b/backend/src/services/discordBotService.test.js @@ -0,0 +1,73 @@ +// @ts-check +/** + * Tests for the Discord bot `/trivela-stats` command (#1349). + * Run with: node --test src/services/discordBotService.test.js + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDiscordBotService } from './discordBotService.js'; + +function makeRepo(campaigns) { + return { list: () => campaigns }; +} + +test('returns zero metrics for an empty campaign list', () => { + const svc = createDiscordBotService({ campaignRepository: makeRepo([]) }); + const stats = svc.getTrivelaStats(); + assert.deepEqual(stats, { active: 0, total: 0, participants: 0, rewardPool: 0 }); +}); + +test('counts active campaigns and sums participants and reward pool', () => { + const svc = createDiscordBotService({ + campaignRepository: makeRepo([ + { + status: 'active', + participants: 10, + rewardPerAction: 5, + maxParticipants: 100, + }, + { + status: 'ended', + participants: 3, + rewardPerAction: 2, + maxParticipants: 50, + }, + { + status: 'draft', + participants: 0, + rewardPerAction: 1, + maxParticipants: 10, + }, + ]), + }); + const stats = svc.getTrivelaStats(); + assert.equal(stats.total, 3); + assert.equal(stats.active, 1); + assert.equal(stats.participants, 13); + assert.equal(stats.rewardPool, 5 * 100 + 2 * 50 + 1 * 10); +}); + +test('buildStatsResponse returns a Discord interaction payload', () => { + const svc = createDiscordBotService({ + campaignRepository: makeRepo([ + { status: 'active', participants: 4, rewardPerAction: 1, maxParticipants: 10 }, + ]), + }); + const res = svc.buildStatsResponse(); + assert.equal(res.type, 4); + assert.match(res.data.content, /Active campaigns: \*\*1\*\*/); + assert.match(res.data.content, /Total participants: \*\*4\*\*/); + assert.match(res.data.content, /Reward pool: \*\*10\*\*/); +}); + +test('uses endDate when status is missing to determine active', () => { + const future = new Date(Date.now() + 86_400_000).toISOString(); + const past = new Date(Date.now() - 86_400_000).toISOString(); + const svc = createDiscordBotService({ + campaignRepository: makeRepo([ + { endDate: future, participants: 1, rewardPerAction: 0, maxParticipants: 0 }, + { endDate: past, participants: 1, rewardPerAction: 0, maxParticipants: 0 }, + ]), + }); + assert.equal(svc.getTrivelaStats().active, 1); +}); diff --git a/backend/src/services/telegramBotService.js b/backend/src/services/telegramBotService.js new file mode 100644 index 00000000..42853b1d --- /dev/null +++ b/backend/src/services/telegramBotService.js @@ -0,0 +1,106 @@ +/** + * Telegram bot service for `/claim` (#1350). + * + * Lets users check claimable reward balances via a Telegram bot command. + * The handler resolves the user's linked wallet identity and returns a + * human-readable summary of claimable rewards. + */ + +/** + * @typedef {{ identity: string, claimable: number, currency: string, streakDays: number }} ClaimSummary + */ + +/** + * @param {{ + * claimableBalanceRepository?: { + * listByRecipient: (identity: string) => Array, + * }, + * streakService?: ReturnType, + * identityResolver?: (telegramId: string) => string | null, + * rewardCurrency?: string, + * }} deps + */ +export function createTelegramBotService({ + claimableBalanceRepository, + streakService, + identityResolver = (id) => id, + rewardCurrency = 'XLM', +} = {}) { + /** + * Resolve claimable reward summary for a Telegram user. + * @param {string} telegramUserId + * @returns {ClaimSummary | null} null when the user has no linked wallet + */ + function getClaimSummary(telegramUserId) { + const identity = identityResolver(telegramUserId); + if (!identity) return null; + + const balances = claimableBalanceRepository?.listByRecipient?.(identity) ?? []; + const claimable = balances + .filter((b) => (b.status ?? 'pending') !== 'claimed') + .reduce((sum, b) => sum + Number(b.amount ?? 0), 0); + + const streak = streakService?.getStreak?.(identity); + const streakDays = streak?.streakDays ?? 0; + + return { + identity, + claimable: Math.round(claimable * 100) / 100, + currency: rewardCurrency, + streakDays, + }; + } + + /** + * Build a Telegram sendMessage payload for the `/claim` command. + * @param {string} telegramUserId + * @returns {{ chat_id: string, text: string }} + */ + function buildClaimResponse(telegramUserId) { + const summary = getClaimSummary(telegramUserId); + + if (!summary) { + return { + chat_id: String(telegramUserId), + text: 'Link your wallet first to check claimable rewards.', + }; + } + + const lines = [ + 'Your claimable rewards:', + `Balance: ${summary.claimable.toLocaleString()} ${summary.currency}`, + `Current streak: ${summary.streakDays} day${summary.streakDays === 1 ? '' : 's'}`, + ]; + + return { + chat_id: String(telegramUserId), + text: lines.join('\n'), + }; + } + + /** + * Handle an incoming Telegram update (webhook body). + * Only `/claim` is supported; other messages get a hint reply. + * @param {{ message?: { chat?: { id?: number|string }, from?: { id?: number|string }, text?: string } }} update + * @returns {{ chat_id: string, text: string } | null} + */ + function handleUpdate(update) { + const message = update?.message; + const chatId = message?.chat?.id ?? message?.from?.id; + if (chatId == null) return null; + + const text = String(message?.text ?? '').trim(); + const command = text.split(/\s+/)[0]?.split('@')[0]?.toLowerCase() ?? ''; + + if (command === '/claim') { + return buildClaimResponse(chatId); + } + + return { + chat_id: String(chatId), + text: 'Available commands:\n/claim — check your claimable reward balance', + }; + } + + return { getClaimSummary, buildClaimResponse, handleUpdate }; +} diff --git a/backend/src/services/telegramBotService.test.js b/backend/src/services/telegramBotService.test.js new file mode 100644 index 00000000..239e87b5 --- /dev/null +++ b/backend/src/services/telegramBotService.test.js @@ -0,0 +1,67 @@ +// @ts-check +/** + * Tests for the Telegram bot `/claim` command (#1350). + * Run with: node --test src/services/telegramBotService.test.js + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTelegramBotService } from './telegramBotService.js'; + +function makeBalanceRepo(balances) { + return { listByRecipient: () => balances }; +} + +test('returns null when identity cannot be resolved', () => { + const svc = createTelegramBotService({ identityResolver: () => null }); + assert.equal(svc.getClaimSummary('tg-1'), null); +}); + +test('sums unclaimed balances and includes streak', () => { + const svc = createTelegramBotService({ + claimableBalanceRepository: makeBalanceRepo([ + { amount: 5, status: 'pending' }, + { amount: 3, status: 'pending' }, + { amount: 10, status: 'claimed' }, + ]), + streakService: { getStreak: () => ({ streakDays: 12 }) }, + }); + const summary = svc.getClaimSummary('tg-1'); + assert.equal(summary.claimable, 8); + assert.equal(summary.streakDays, 12); + assert.equal(summary.currency, 'XLM'); +}); + +test('buildClaimResponse returns a Telegram sendMessage payload', () => { + const svc = createTelegramBotService({ + claimableBalanceRepository: makeBalanceRepo([{ amount: 7, status: 'pending' }]), + streakService: { getStreak: () => ({ streakDays: 7 }) }, + }); + const reply = svc.buildClaimResponse('tg-42'); + assert.equal(reply.chat_id, 'tg-42'); + assert.match(reply.text, /Balance: 7 XLM/); + assert.match(reply.text, /Current streak: 7 days/); +}); + +test('handleUpdate responds to /claim command', () => { + const svc = createTelegramBotService({ + claimableBalanceRepository: makeBalanceRepo([{ amount: 1, status: 'pending' }]), + }); + const reply = svc.handleUpdate({ + message: { chat: { id: 99 }, text: '/claim@TrivelaBot' }, + }); + assert.equal(reply.chat_id, '99'); + assert.match(reply.text, /claimable rewards/i); +}); + +test('handleUpdate returns help text for unknown commands', () => { + const svc = createTelegramBotService({}); + const reply = svc.handleUpdate({ + message: { chat: { id: 1 }, text: '/unknown' }, + }); + assert.match(reply.text, /Available commands/); +}); + +test('handleUpdate returns null when chat id is missing', () => { + const svc = createTelegramBotService({}); + assert.equal(svc.handleUpdate({}), null); +}); diff --git a/frontend/src/components/CampaignRoiCalculator.css b/frontend/src/components/CampaignRoiCalculator.css new file mode 100644 index 00000000..9d10758a --- /dev/null +++ b/frontend/src/components/CampaignRoiCalculator.css @@ -0,0 +1,81 @@ +.roi-calculator { + max-width: 520px; + padding: 1.5rem; + border: 1px solid var(--border, #334155); + border-radius: 8px; + background: var(--bg-card, #1e293b); + color: var(--text, #e2e8f0); +} + +.roi-calculator-title { + margin: 0 0 0.25rem; + font-size: 1.25rem; + font-weight: 600; +} + +.roi-calculator-subtitle { + margin: 0 0 1.25rem; + font-size: 0.875rem; + color: var(--text-muted, #94a3b8); +} + +.roi-calculator-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.roi-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8125rem; + color: var(--text-muted, #94a3b8); +} + +.roi-field input { + padding: 0.5rem; + border: 1px solid var(--border, #334155); + border-radius: 4px; + background: var(--bg-input, #0f172a); + color: var(--text, #e2e8f0); + font-size: 0.9375rem; +} + +.roi-calculator-results { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.roi-result-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border, #334155); + font-size: 0.875rem; +} + +.roi-result-highlight { + border-bottom: none; + border-top: 2px solid var(--border, #334155); + margin-top: 0.25rem; + padding-top: 0.75rem; + font-size: 1rem; +} + +.roi-negative { + color: #f87171; +} + +.roi-positive { + color: #4ade80; +} + +@media (max-width: 540px) { + .roi-calculator-form { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/components/CampaignRoiCalculator.jsx b/frontend/src/components/CampaignRoiCalculator.jsx new file mode 100644 index 00000000..8948f16a --- /dev/null +++ b/frontend/src/components/CampaignRoiCalculator.jsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from 'react'; +import './CampaignRoiCalculator.css'; + +const number = (v) => { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : 0; +}; + +export default function CampaignRoiCalculator({ rewardCurrency = 'XLM' }) { + const [budget, setBudget] = useState(''); + const [rewardPerAction, setRewardPerAction] = useState(''); + const [expectedParticipants, setExpectedParticipants] = useState(''); + const [conversionRate, setConversionRate] = useState(''); + + const results = useMemo(() => { + const b = number(budget); + const rpa = number(rewardPerAction); + const participants = number(expectedParticipants); + const conversion = number(conversionRate) / 100; + + const totalCost = rpa * participants; + const costPerParticipant = participants > 0 ? totalCost / participants : 0; + const remainingBudget = b - totalCost; + const convertedUsers = Math.round(participants * conversion); + const estimatedRevenue = convertedUsers * costPerParticipant * 3; + const roi = totalCost > 0 ? ((estimatedRevenue - totalCost) / totalCost) * 100 : 0; + + return { + totalCost: round2(totalCost), + costPerParticipant: round2(costPerParticipant), + remainingBudget: round2(remainingBudget), + convertedUsers, + estimatedRevenue: round2(estimatedRevenue), + roi: round2(roi), + }; + }, [budget, rewardPerAction, expectedParticipants, conversionRate]); + + return ( +
+

Campaign ROI Calculator

+

+ Estimate engagement and retention ROI for your campaign sponsorship. +

+ +
+ + + + +
+ +
+
+ Total campaign cost + {results.totalCost.toLocaleString()} {rewardCurrency} +
+
+ Cost per participant + {results.costPerParticipant.toLocaleString()} {rewardCurrency} +
+
+ Remaining budget + + {results.remainingBudget.toLocaleString()} {rewardCurrency} + +
+
+ Estimated converted users + {results.convertedUsers.toLocaleString()} +
+
+ Estimated revenue + {results.estimatedRevenue.toLocaleString()} {rewardCurrency} +
+
+ Estimated ROI + + {results.roi.toLocaleString()}% + +
+
+
+ ); +} + +function round2(n) { + return Math.round(n * 100) / 100; +} diff --git a/frontend/src/components/CampaignRoiCalculator.test.jsx b/frontend/src/components/CampaignRoiCalculator.test.jsx new file mode 100644 index 00000000..3561e18b --- /dev/null +++ b/frontend/src/components/CampaignRoiCalculator.test.jsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CampaignRoiCalculator from './CampaignRoiCalculator'; + +describe('CampaignRoiCalculator', () => { + it('renders the calculator', () => { + render(); + expect(screen.getByTestId('roi-calculator')).toBeDefined(); + expect(screen.getByText('Campaign ROI Calculator')).toBeDefined(); + }); + + it('computes results from user input', () => { + render(); + const inputs = screen.getAllByRole('spinbutton'); + fireEvent.change(inputs[0], { target: { value: '1000' } }); + fireEvent.change(inputs[1], { target: { value: '5' } }); + fireEvent.change(inputs[2], { target: { value: '100' } }); + fireEvent.change(inputs[3], { target: { value: '20' } }); + + const results = screen.getByTestId('roi-results'); + expect(results.textContent).toContain('500'); + expect(results.textContent).toContain('20'); + expect(results.textContent).toContain('ROI'); + }); + + it('shows zero values when inputs are empty', () => { + render(); + const results = screen.getByTestId('roi-results'); + expect(results.textContent).toContain('0'); + }); + + it('handles invalid negative input gracefully', () => { + render(); + const inputs = screen.getAllByRole('spinbutton'); + fireEvent.change(inputs[0], { target: { value: '-5' } }); + fireEvent.change(inputs[1], { target: { value: 'abc' } }); + const results = screen.getByTestId('roi-results'); + expect(results.textContent).toContain('0'); + }); +}); diff --git a/frontend/src/components/StreakCelebrationModal.css b/frontend/src/components/StreakCelebrationModal.css new file mode 100644 index 00000000..10004f31 --- /dev/null +++ b/frontend/src/components/StreakCelebrationModal.css @@ -0,0 +1,66 @@ +.streak-celebration-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1100; +} + +.streak-celebration { + background: var(--bg-card, #1e293b); + border: 1px solid var(--border, #334155); + border-radius: 12px; + padding: 2.5rem 2rem; + max-width: 380px; + width: 90%; + text-align: center; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + animation: streak-pop 0.35s ease-out; +} + +.streak-celebration-emoji { + font-size: 3.5rem; + line-height: 1; + margin-bottom: 1rem; + animation: streak-bounce 0.6s ease-in-out infinite alternate; +} + +.streak-celebration-title { + margin: 0 0 0.5rem; + font-size: 1.5rem; + font-weight: 700; + color: var(--text, #e2e8f0); +} + +.streak-celebration-message { + margin: 0 0 1.5rem; + font-size: 0.9375rem; + color: var(--text-muted, #94a3b8); + line-height: 1.5; +} + +.streak-celebration-close { + min-width: 140px; +} + +@keyframes streak-pop { + 0% { + transform: scale(0.85); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes streak-bounce { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(-8px); + } +} diff --git a/frontend/src/components/StreakCelebrationModal.jsx b/frontend/src/components/StreakCelebrationModal.jsx new file mode 100644 index 00000000..dbbf2a75 --- /dev/null +++ b/frontend/src/components/StreakCelebrationModal.jsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; +import './StreakCelebrationModal.css'; + +const MILESTONES = { + 7: { title: '7-Day Streak!', emoji: '🔥', message: 'You have kept your streak alive for a week. Keep going!' }, + 30: { title: '30-Day Streak!', emoji: '🏆', message: 'Incredible dedication — a full month of activity. Legendary status unlocked!' }, +}; + +export default function StreakCelebrationModal({ streakDays, onClose }) { + const closeRef = useRef(null); + const milestone = MILESTONES[streakDays]; + + useEffect(() => { + if (milestone) closeRef.current?.focus(); + }, [milestone]); + + useEffect(() => { + if (!milestone) return; + const onKey = (e) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [milestone, onClose]); + + if (!milestone) return null; + + return ( +
+
e.stopPropagation()}> + +

+ {milestone.title} +

+

{milestone.message}

+ +
+
+ ); +} diff --git a/frontend/src/components/StreakCelebrationModal.test.jsx b/frontend/src/components/StreakCelebrationModal.test.jsx new file mode 100644 index 00000000..1b3695c9 --- /dev/null +++ b/frontend/src/components/StreakCelebrationModal.test.jsx @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import StreakCelebrationModal from './StreakCelebrationModal'; +import { isStreakMilestone } from '../utils/streakMilestone'; + +describe('StreakCelebrationModal', () => { + it('renders nothing for non-milestone streaks', () => { + const { container } = render( {}} />); + expect(container.innerHTML).toBe(''); + }); + + it('shows celebration for 7-day milestone', () => { + render( {}} />); + expect(screen.getByTestId('streak-celebration-modal')).toBeDefined(); + expect(screen.getByText('7-Day Streak!')).toBeDefined(); + }); + + it('shows celebration for 30-day milestone', () => { + render( {}} />); + expect(screen.getByText('30-Day Streak!')).toBeDefined(); + }); + + it('calls onClose when the button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /keep it up/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose on Escape key', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe('isStreakMilestone', () => { + it('returns true for 7 and 30', () => { + expect(isStreakMilestone(7)).toBe(true); + expect(isStreakMilestone(30)).toBe(true); + }); + + it('returns false for other values', () => { + expect(isStreakMilestone(5)).toBe(false); + expect(isStreakMilestone(0)).toBe(false); + expect(isStreakMilestone(31)).toBe(false); + }); +}); diff --git a/frontend/src/utils/streakMilestone.js b/frontend/src/utils/streakMilestone.js new file mode 100644 index 00000000..1e9767f2 --- /dev/null +++ b/frontend/src/utils/streakMilestone.js @@ -0,0 +1,3 @@ +export function isStreakMilestone(streakDays) { + return streakDays === 7 || streakDays === 30; +}