diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c21636..53e84c3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, lazy, Suspense } from 'react'; -import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'; +import { Routes, Route, NavLink, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { userSession, authenticate, disconnect, getMainnetAddress, isValidUserData } from './utils/stacks'; import Header from './components/Header'; import SkipNav from './components/SkipNav'; @@ -8,7 +8,7 @@ import RequireAdmin from './components/RequireAdmin'; import RequireAuth from './components/RequireAuth'; import LazyErrorBoundary from './components/LazyErrorBoundary'; import OfflineBanner from './components/OfflineBanner'; -import DemoIndicator from './components/DemoIndicator'; +import { DemoIndicator } from './components/DemoIndicator'; import { ToastContainer, useToast } from './components/ui/toast'; import { analytics } from './lib/analytics'; import { useNotifications } from './hooks/useNotifications'; @@ -16,6 +16,7 @@ import { useContractHealth } from './hooks/useContractHealth'; import { useAdmin } from './hooks/useAdmin'; import { usePageTitle } from './hooks/usePageTitle'; import { useSessionSync } from './hooks/useSessionSync'; +import { useDemoMode } from './context/DemoContext'; import { ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_FEED, ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, @@ -23,6 +24,7 @@ import { DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META, } from './config/routes'; import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge } from 'lucide-react'; +import { activateDemo, deactivateDemo } from './lib/demo-utils'; const AnimatedHero = lazy(() => import('./components/ui/animated-hero').then(m => ({ default: m.AnimatedHero }))); const MaintenancePage = lazy(() => import('./components/MaintenancePage')); @@ -42,9 +44,12 @@ const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard')) function App() { const [userData, setUserData] = useState(null); const [authLoading, setAuthLoading] = useState(false); + const [demoLoading, setDemoLoading] = useState(false); const { toasts, addToast, removeToast } = useToast(); const location = useLocation(); + const navigate = useNavigate(); const { healthy, error: healthError, checking: healthChecking, retry: retryHealth } = useContractHealth(); + const { demoEnabled, toggleDemo } = useDemoMode(); const userAddress = getMainnetAddress(userData); const { notifications, unreadCount, lastSeenTimestamp, markAllRead, loading: notificationsLoading } = useNotifications(userAddress); @@ -84,6 +89,15 @@ function App() { }, [location.pathname]); const handleAuth = async () => { + if (demoEnabled) { + deactivateDemo(); + toggleDemo(false); + setDemoLoading(false); + navigate(ROUTE_FEED, { replace: true }); + addToast('Demo mode exited.', 'info'); + return; + } + if (userData) { disconnect(); setUserData(null); @@ -115,6 +129,17 @@ function App() { } }; + const handleTryDemo = () => { + setDemoLoading(true); + activateDemo(); + toggleDemo(true); + setUserData(null); + setAuthLoading(false); + navigate(ROUTE_SEND, { replace: true }); + addToast('Demo mode started. No wallet required.', 'success'); + setDemoLoading(false); + }; + const navItems = useMemo(() => { const allItems = [ { path: ROUTE_SEND, label: 'Send Tip', icon: Zap }, @@ -132,7 +157,7 @@ function App() { const items = allItems.filter((item) => { const meta = ROUTE_META[item.path]; // Show authenticated routes only if user is authenticated - if (meta.requiresAuth && !userData) return false; + if (meta.requiresAuth && !userData && !demoEnabled) return false; // Show admin routes only if user is owner if (meta.adminOnly && !isOwner) return false; return true; @@ -143,7 +168,7 @@ function App() { items.push({ path: ROUTE_TELEMETRY, label: 'Telemetry', icon: Gauge }); } return items; - }, [userData, isOwner]); + }, [userData, isOwner, demoEnabled]); if (healthy === false) { return ( @@ -167,6 +192,7 @@ function App() { userData={userData} onAuth={handleAuth} authLoading={authLoading} + demoEnabled={demoEnabled} notifications={notifications} unreadCount={unreadCount} lastSeenTimestamp={lastSeenTimestamp} @@ -177,9 +203,9 @@ function App() {
{/* Show landing hero only if user has not connected AND is on home route */} - {!userData && location.pathname === '/' ? ( + {!userData && !demoEnabled && location.pathname === '/' ? ( }> - + ) : (
@@ -223,7 +249,7 @@ function App() { ) : ( @@ -235,7 +261,7 @@ function App() { ) : ( @@ -247,7 +273,7 @@ function App() { ) : ( @@ -266,7 +292,7 @@ function App() { ) : ( @@ -278,7 +304,7 @@ function App() { ) : ( @@ -290,7 +316,7 @@ function App() { ) : ( @@ -305,7 +331,7 @@ function App() { } /> {/* Root and fallback */} - } /> + } /> } /> diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index bf988c8..ba58096 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -21,12 +21,13 @@ const NotificationBell = lazy(() => import('./NotificationBell')); * @param {Object|null} props.userData - Authenticated user session data. * @param {Function} props.onAuth - Callback for connect/disconnect action. * @param {boolean} props.authLoading - Whether authentication is in progress. + * @param {boolean} props.demoEnabled - Whether demo mode is active. * @param {Array} props.notifications - List of notification objects. * @param {number} props.unreadCount - Number of unread notifications. * @param {Function} props.onMarkNotificationsRead - Callback to mark all read. * @param {boolean} props.notificationsLoading - Whether notifications are loading. */ -export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, lastSeenTimestamp, onMarkNotificationsRead, notificationsLoading, apiReachable = null }) { +export default function Header({ userData, onAuth, authLoading, demoEnabled, notifications, unreadCount, lastSeenTimestamp, onMarkNotificationsRead, notificationsLoading, apiReachable = null }) { const { theme, toggleTheme } = useTheme(); const isOnline = useOnlineStatus(); @@ -53,6 +54,11 @@ export default function Header({ userData, onAuth, authLoading, notifications, u />

TipStream

+ {demoEnabled && ( + + Demo + + )}
- {authLoading ? 'Connecting...' : userData ? 'Disconnect' : 'Connect Wallet'} + {authLoading ? 'Connecting...' : demoEnabled ? 'Exit Demo' : userData ? 'Disconnect' : 'Connect Wallet'}
diff --git a/frontend/src/components/PlatformStats.jsx b/frontend/src/components/PlatformStats.jsx index abf6803..cfc6941 100644 --- a/frontend/src/components/PlatformStats.jsx +++ b/frontend/src/components/PlatformStats.jsx @@ -4,15 +4,33 @@ import { network } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_GET_PLATFORM_STATS } from '../config/contracts'; import { formatSTX } from '../lib/utils'; import { useTipContext } from '../context/TipContext'; +import { useDemoMode } from '../context/DemoContext'; +import { useDemoStats } from '../hooks/useDemoStats'; export default function PlatformStats() { const { refreshCounter } = useTipContext(); + const { demoEnabled } = useDemoMode(); + const { getDemoStats } = useDemoStats(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); + const demoStats = demoEnabled ? getDemoStats() : null; + const fetchPlatformStats = useCallback(async () => { + if (demoEnabled) { + setStats({ + 'total-tips': { value: demoStats?.platformStats.totalTipsOnPlatform ?? 0 }, + 'total-volume': { value: demoStats?.totalAmount ?? 0 }, + 'platform-fees': { value: Math.round((demoStats?.totalAmount ?? 0) * 0.005) }, + }); + setLoading(false); + setError(null); + setLastRefresh(new Date()); + return; + } + try { const result = await fetchCallReadOnlyFunction({ network, @@ -31,7 +49,7 @@ export default function PlatformStats() { setError(isNet ? 'Unable to reach the Stacks API. Check your connection.' : `Failed to load stats: ${err.message}`); setLoading(false); } - }, []); + }, [demoEnabled, demoStats]); useEffect(() => { Promise.resolve().then(() => fetchPlatformStats()); @@ -66,16 +84,23 @@ export default function PlatformStats() { return (
-

Global Impact

+
+

Global Impact

+ {demoEnabled && ( + + Demo + + )} +
{lastRefresh && {lastRefresh.toLocaleTimeString()}} - - Live - + + Live +
diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx index c3e0070..a094774 100644 --- a/frontend/src/components/RecentTips.jsx +++ b/frontend/src/components/RecentTips.jsx @@ -8,6 +8,7 @@ import { network, appDetails, userSession, getSenderAddress } from '../utils/sta import { clearTipCache } from '../lib/fetchTipDetails'; import { validateTipBackAmount, MIN_TIP_STX, MAX_TIP_STX } from '../lib/tipBackValidation'; import { useTipContext } from '../context/TipContext'; +import { useDemoMode } from '../context/DemoContext'; import { useFilteredAndPaginatedEvents } from '../hooks/useFilteredAndPaginatedEvents'; import { Zap, Search } from 'lucide-react'; import CopyButton from './ui/copy-button'; @@ -23,7 +24,9 @@ export default function RecentTips({ addToast }) { lastEventRefresh, refreshEvents, loadMoreEvents: contextLoadMore, + addDemoTip, } = useTipContext(); + const { demoEnabled, setDemoBalance } = useDemoMode(); const { enrichedTips: allEnrichedTips, @@ -153,6 +156,22 @@ export default function RecentTips({ addToast }) { * @param {Object} tip - The tip event to reciprocate. */ const handleTipBack = async (tip) => { + if (demoEnabled) { + const microSTX = toMicroSTX(tipBackAmount); + setDemoBalance((prev) => Math.max(0, prev - microSTX)); + addDemoTip({ + recipient: tip.sender, + amount: microSTX, + message: tipBackMessage || 'Tipping back!', + category: tip.category, + }); + setSending(false); + closeTipBackModal(); + setTipBackMessage(''); + addToast?.('Demo tip-a-tip sent!', 'success'); + return; + } + if (!userSession.isUserSignedIn()) return; // Validate the amount before opening the wallet prompt (Issue #233). diff --git a/frontend/src/components/RequireAuth.jsx b/frontend/src/components/RequireAuth.jsx index fc21e6c..df01cd2 100644 --- a/frontend/src/components/RequireAuth.jsx +++ b/frontend/src/components/RequireAuth.jsx @@ -8,7 +8,7 @@ */ import { Link } from 'react-router-dom'; -export default function RequireAuth({ children, onAuth, authLoading, route }) { +export default function RequireAuth({ children, onAuth, authLoading }) { return (
{children} diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index 170f85e..971aeb7 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -13,6 +13,8 @@ import { isValidStacksPrincipal } from '../lib/stacks-principal'; import { canProceedWithRecipient, getRecipientValidationMessage } from '../lib/recipient-validation'; import { tipPostCondition, maxTransferForTip, feeForTip, totalDeduction, recipientReceives, SAFE_POST_CONDITION_MODE, FEE_PERCENT } from '../lib/post-conditions'; import { useTipContext } from '../context/TipContext'; +import { useDemoMode } from '../context/DemoContext'; +import { useSendTipWithDemo } from '../hooks/useSendTipWithDemo'; import { useBalance } from '../hooks/useBalance'; import { useBlockCheck } from '../hooks/useBlockCheck'; import { useStxPrice } from '../hooks/useStxPrice'; @@ -46,9 +48,10 @@ const TIP_CATEGORIES = [ * @param {Function} props.addToast - Callback to display a toast notification. */ export default function SendTip({ addToast }) { - const { notifyTipSent } = useTipContext(); - const { toUsd } = useStxPrice(); - const { blocked: blockedWarning, checkBlocked, reset: resetBlockCheck } = useBlockCheck(); + const { notifyTipSent } = useTipContext(); + const { demoEnabled, getDemoData } = useDemoMode(); + const { toUsd } = useStxPrice(); + const { blocked: blockedWarning, checkBlocked, reset: resetBlockCheck } = useBlockCheck(); const [recipient, setRecipient] = useState(''); const [amount, setAmount] = useState(''); const [message, setMessage] = useState(''); @@ -59,11 +62,20 @@ export default function SendTip({ addToast }) { const [recipientError, setRecipientError] = useState(''); const [amountError, setAmountError] = useState(''); const [cooldown, setCooldown] = useState(0); - const cooldownRef = useRef(null); - - const senderAddress = useMemo(() => getSenderAddress(), []); - - const { balance, balanceStx: balanceSTX, loading: balanceLoading, refetch: refetchBalance } = useBalance(senderAddress); + const cooldownRef = useRef(null); + + const senderAddress = useMemo( + () => (demoEnabled ? getDemoData().mockWalletAddress : getSenderAddress()), + [demoEnabled, getDemoData], + ); + const realBalanceState = useBalance(senderAddress); + const { displayBalance: balance, sendTipInDemo, pendingTransaction } = useSendTipWithDemo(realBalanceState.balance); + const balanceLoading = demoEnabled ? false : realBalanceState.loading; + const refetchBalance = demoEnabled ? realBalanceState.refetch : realBalanceState.refetch; + const balanceSTX = useMemo(() => { + if (balance === null || balance === undefined) return null; + return typeof balance === 'string' ? Number(balance) / 1000000 : balance / 1000000; + }, [balance]); const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning); @@ -158,6 +170,19 @@ export default function SendTip({ addToast }) { setLoading(true); try { + if (demoEnabled) { + const result = await sendTipInDemo(recipient.trim(), amount, message, category); + setPendingTx(result); + setRecipient(''); + setAmount(''); + setMessage(''); + setCategory(0); + startCooldown(); + addToast('Demo tip sent successfully.', 'success'); + setLoading(false); + return; + } + const microSTX = toMicroSTX(amount); const postConditions = [ tipPostCondition(senderAddress, microSTX) @@ -239,7 +264,7 @@ export default function SendTip({ addToast }) { ? formatBalance(balance) : 'Unavailable'}

- {pendingTx && ( + {(pendingTx || pendingTransaction) && (

Pending confirmation

diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx index db4e73f..b5105e9 100644 --- a/frontend/src/components/TipHistory.jsx +++ b/frontend/src/components/TipHistory.jsx @@ -5,6 +5,7 @@ import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_GET_USER_STATS, STACKS_API_BASE } f import { formatSTX, formatAddress } from '../lib/utils'; import CopyButton from './ui/copy-button'; import ShareTip from './ShareTip'; +import { useDemoMode } from '../context/DemoContext'; const CATEGORY_LABELS = { 0: 'General', 1: 'Content Creation', 2: 'Open Source', @@ -42,6 +43,7 @@ function parseUtf8(repr) { * @param {string} props.userAddress - The STX address of the logged-in user. */ export default function TipHistory({ userAddress }) { + const { demoEnabled, getDemoData } = useDemoMode(); const [tips, setTips] = useState([]); const [tipsLoading, setTipsLoading] = useState(true); const [tipsError, setTipsError] = useState(null); @@ -54,8 +56,36 @@ export default function TipHistory({ userAddress }) { const [loadingMore, setLoadingMore] = useState(false); const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; + const demoWalletAddress = demoEnabled ? getDemoData().mockWalletAddress : null; + + const buildDemoTips = useCallback(() => { + const walletAddress = demoWalletAddress; + return getDemoData().mockTips + .filter((tip) => tip.sender === walletAddress || tip.recipient === walletAddress) + .map((tip) => ({ + tipId: tip.id, + txId: tip.id, + sender: tip.sender, + recipient: tip.recipient, + amount: String(tip.amount), + message: tip.memo || '', + category: tip.category ?? null, + direction: tip.sender === walletAddress ? 'sent' : 'received', + timestamp: tip.timestamp || null, + })); + }, [demoWalletAddress, getDemoData]); const fetchTips = useCallback(async (reset = true) => { + if (demoEnabled) { + const parsed = buildDemoTips(); + setTips(parsed); + setTipsLoading(false); + setTipsError(null); + setTipsMeta({ offset: parsed.length, total: parsed.length, hasMore: false }); + setLastTipsRefresh(new Date()); + return; + } + if (!userAddress) { setTips([]); setTipsLoading(false); @@ -121,7 +151,7 @@ export default function TipHistory({ userAddress }) { } finally { setTipsLoading(false); } - }, [userAddress, tipsMeta.offset, contractId]); + }, [userAddress, tipsMeta.offset, contractId, demoEnabled, buildDemoTips]); const handleRefresh = useCallback(() => { fetchTips(true); @@ -139,6 +169,20 @@ export default function TipHistory({ userAddress }) { // Fetch on-chain user stats (tips sent/received counts and volume). // This is user-specific data not available from the shared event cache. const fetchUserStats = useCallback(async () => { + if (demoEnabled) { + const demoTips = buildDemoTips(); + const sent = demoTips.filter((tip) => tip.direction === 'sent'); + const received = demoTips.filter((tip) => tip.direction === 'received'); + setStats({ + 'tips-sent': { value: sent.length }, + 'tips-received': { value: received.length }, + 'total-sent': { value: sent.reduce((sum, tip) => sum + Number(tip.amount || 0), 0) }, + 'total-received': { value: received.reduce((sum, tip) => sum + Number(tip.amount || 0), 0) }, + }); + setStatsLoading(false); + return; + } + if (!userAddress) return; try { const result = await fetchCallReadOnlyFunction({ @@ -151,7 +195,7 @@ export default function TipHistory({ userAddress }) { } finally { setStatsLoading(false); } - }, [userAddress]); + }, [userAddress, demoEnabled, buildDemoTips]); useEffect(() => { fetchUserStats(); }, [fetchUserStats]); @@ -193,7 +237,14 @@ export default function TipHistory({ userAddress }) { return (
-

Your Activity

+
+

Your Activity

+ {demoEnabled && ( + + Demo + + )} +
{lastTipsRefresh && {lastTipsRefresh.toLocaleTimeString()}} diff --git a/frontend/src/components/ui/animated-hero.jsx b/frontend/src/components/ui/animated-hero.jsx index 9ccf6f6..fef189a 100644 --- a/frontend/src/components/ui/animated-hero.jsx +++ b/frontend/src/components/ui/animated-hero.jsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; const TITLE_ROTATION_INTERVAL_MS = 2200; -function AnimatedHero({ onGetStarted, loading }) { +function AnimatedHero({ onGetStarted, onTryDemo, loading, demoLoading }) { const [titleNumber, setTitleNumber] = useState(0); const titles = useMemo( () => ["instant", "secure", "transparent", "effortless", "unstoppable"], @@ -96,6 +96,13 @@ function AnimatedHero({ onGetStarted, loading }) { )} + {}, + getDemoData: () => getDemo(), + demoBalance: getDemo().mockBalance, + setDemoBalance: () => {}, + demoTips: [], + addDemoTip: () => {}, + clearDemoHistory: () => {}, + resetDemoState: () => {}, +}); export function useDemoMode() { const context = useContext(DemoContext); - if (!context) { - throw new Error('useDemoMode must be used within DemoProvider'); - } return context; } @@ -16,20 +23,64 @@ export function DemoProvider({ children }) { loadDemoPreference(); return isDemo(); }); + const [demoBalance, setDemoBalance] = useState(() => getDemo().mockBalance); + const [demoTips, setDemoTips] = useState(() => [...getDemo().mockTips]); const toggleDemo = (enabled) => { setDemoMode(enabled); setDemoEnabled(enabled); + if (enabled) { + setDemoBalance(getDemo().mockBalance); + setDemoTips([...getDemo().mockTips]); + return; + } + + setDemoBalance(getDemo().mockBalance); + setDemoTips([]); }; const getDemoData = () => { return getDemo(); }; + const resetDemoState = () => { + setDemoBalance(getDemo().mockBalance); + setDemoTips([...getDemo().mockTips]); + }; + + const addDemoTip = (tipData) => { + if (!demoEnabled) { + return; + } + + setDemoTips((currentTips) => [ + { + id: `demo-${Date.now()}`, + sender: getDemo().mockWalletAddress, + recipient: tipData.recipient, + amount: tipData.amount, + memo: tipData.memo || tipData.message || '', + timestamp: Date.now(), + category: tipData.category ?? null, + }, + ...currentTips, + ]); + }; + + const clearDemoHistory = () => { + setDemoTips([]); + }; + const value = { demoEnabled, toggleDemo, getDemoData, + demoBalance, + setDemoBalance, + demoTips, + addDemoTip, + clearDemoHistory, + resetDemoState, }; return ( diff --git a/frontend/src/context/TipContext.jsx b/frontend/src/context/TipContext.jsx index e15195c..24e8e56 100644 --- a/frontend/src/context/TipContext.jsx +++ b/frontend/src/context/TipContext.jsx @@ -11,6 +11,7 @@ import { createContext, useContext, useReducer, useCallback, useState, useEffect import { fetchAllContractEvents, POLL_INTERVAL_MS } from '../lib/contractEvents'; import { updatePaginationState } from '../lib/eventPageCache'; import { mergeAndDeduplicateEvents, sortEventsStably } from '../lib/eventDeduplication.js'; +import { useDemoMode } from './DemoContext'; const TipContext = createContext(null); @@ -39,6 +40,7 @@ function tipReducer(state, action) { export function TipProvider({ children }) { const [state, dispatch] = useReducer(tipReducer, initialState); + const { demoEnabled, demoTips, addDemoTip } = useDemoMode(); // ---- Shared event cache ------------------------------------------------ const [events, setEvents] = useState([]); @@ -48,6 +50,21 @@ export function TipProvider({ children }) { const [lastEventRefresh, setLastEventRefresh] = useState(null); const fetchIdRef = useRef(0); + const demoEvents = useCallback(() => { + return demoTips.map((tip, index) => ({ + event: 'tip-sent', + tipId: tip.id || `demo-tip-${index}`, + sender: tip.sender, + recipient: tip.recipient, + amount: String(tip.amount), + fee: '0', + message: tip.memo || '', + category: tip.category ?? null, + timestamp: tip.timestamp || Date.now(), + txId: tip.id || `demo-tip-${index}`, + })); + }, [demoTips]); + /** * Fetch contract events from the Stacks API and update the shared cache. * Uses a fetchId counter to discard stale responses when a newer fetch @@ -56,6 +73,16 @@ export function TipProvider({ children }) { * Also invalidates page cache to ensure fresh pagination data. */ const refreshEvents = useCallback(async () => { + if (demoEnabled) { + const demoEventData = demoEvents(); + setEvents(demoEventData); + setEventsMeta({ apiOffset: demoEventData.length, total: demoEventData.length, hasMore: false }); + setLastEventRefresh(new Date()); + setEventsLoading(false); + setEventsError(null); + return; + } + const id = ++fetchIdRef.current; try { setEventsError(null); @@ -73,13 +100,17 @@ export function TipProvider({ children }) { } finally { if (id === fetchIdRef.current) setEventsLoading(false); } - }, []); + }, [demoEnabled, demoEvents]); /** * Load the next batch of events beyond the current apiOffset. * Appends new events to the existing cache rather than replacing it. */ const loadMoreEvents = useCallback(async () => { + if (demoEnabled) { + return; + } + try { const result = await fetchAllContractEvents({ startOffset: eventsMeta.apiOffset }); // Merge and deduplicate to prevent duplicates from pagination overlap @@ -90,30 +121,50 @@ export function TipProvider({ children }) { } catch (err) { console.error('Failed to load more events:', err.message || err); } - }, [eventsMeta.apiOffset, events]); + }, [demoEnabled, eventsMeta.apiOffset, events]); const notifyTipSent = useCallback(() => { + if (demoEnabled) { + return; + } dispatch({ type: 'TIP_SENT' }); - }, []); + }, [demoEnabled]); const triggerRefresh = useCallback(() => { + if (demoEnabled) { + const demoEventData = demoEvents(); + setEvents(demoEventData); + setEventsMeta({ apiOffset: demoEventData.length, total: demoEventData.length, hasMore: false }); + setLastEventRefresh(new Date()); + return; + } dispatch({ type: 'REFRESH' }); - }, []); + }, [demoEnabled, demoEvents]); + + const handleDemoTipAdded = useCallback((tipData) => { + addDemoTip(tipData); + triggerRefresh(); + }, [addDemoTip, triggerRefresh]); // Fetch events on mount and whenever refreshCounter bumps. - useEffect(() => { refreshEvents(); }, [refreshEvents, state.refreshCounter]); + useEffect(() => { refreshEvents(); }, [refreshEvents, state.refreshCounter, demoEnabled, demoEvents]); // Single polling interval shared across all consumers. useEffect(() => { + if (demoEnabled) { + return () => {}; + } + const id = setInterval(refreshEvents, POLL_INTERVAL_MS); return () => clearInterval(id); - }, [refreshEvents]); + }, [demoEnabled, refreshEvents]); return ( { - return getDemo().mockBalance; - }); - - const deductBalance = useCallback((amount) => { - setDemoBalance(prev => Math.max(0, prev - amount)); - }, []); - - const addBalance = useCallback((amount) => { - setDemoBalance(prev => prev + amount); - }, []); - - const resetBalance = useCallback(() => { - setDemoBalance(getDemo().mockBalance); - }, []); + const { demoEnabled, demoBalance, setDemoBalance, resetDemoState } = useDemoMode(); + const deductBalance = (amount) => { + setDemoBalance((prev) => Math.max(0, prev - amount)); + }; + const addBalance = (amount) => { + setDemoBalance((prev) => prev + amount); + }; + const resetBalance = () => { + resetDemoState(); + }; return { balance: demoEnabled ? demoBalance : realBalance, diff --git a/frontend/src/hooks/useDemoHistory.js b/frontend/src/hooks/useDemoHistory.js index 3164664..8e92b1c 100644 --- a/frontend/src/hooks/useDemoHistory.js +++ b/frontend/src/hooks/useDemoHistory.js @@ -1,40 +1,8 @@ -import { useState, useCallback, useEffect } from 'react'; import { useDemoMode } from '../context/DemoContext'; -import { getDemo } from '../config/demo'; export function useDemoHistory() { - const { demoEnabled } = useDemoMode(); - const [demoTips, setDemoTips] = useState(() => { - return demoEnabled ? [...getDemo().mockTips] : []; - }); - - useEffect(() => { - if (demoEnabled) { - setDemoTips([...getDemo().mockTips]); - } - }, [demoEnabled]); - - const addDemoTip = useCallback((tipData) => { - if (!demoEnabled) return; - - const newTip = { - id: 'demo-' + Date.now(), - sender: getDemo().mockWalletAddress, - ...tipData, - timestamp: Date.now(), - }; - - setDemoTips(prev => [newTip, ...prev]); - }, [demoEnabled]); - - const getDemoHistory = useCallback(() => { - if (!demoEnabled) return []; - return demoTips; - }, [demoEnabled, demoTips]); - - const clearDemoHistory = useCallback(() => { - setDemoTips([]); - }, []); + const { demoEnabled, demoTips, addDemoTip, clearDemoHistory } = useDemoMode(); + const getDemoHistory = () => demoTips; return { demoTips, diff --git a/frontend/src/hooks/useDemoLeaderboard.js b/frontend/src/hooks/useDemoLeaderboard.js index 151abe4..3873a59 100644 --- a/frontend/src/hooks/useDemoLeaderboard.js +++ b/frontend/src/hooks/useDemoLeaderboard.js @@ -1,38 +1,47 @@ import { useDemoMode } from '../context/DemoContext'; -import { getDemo } from '../config/demo'; - -function generateDemoLeaderboard() { - const demo = getDemo(); - const mockUsers = [ - { address: 'SP1RVJEX1ZZJN3D6JXVCP3N2S4N4S4S4S4S', name: 'Alice', tips: 1500 }, - { address: 'SP2TJVJEX1ZZJN3D6JXVCP3N2S4N4S4S4S4', name: 'Bob', tips: 1200 }, - { address: 'SP3TJVJEX1ZZJN3D6JXVCP3N2S4N4S4S4S4', name: 'Charlie', tips: 950 }, - { address: 'SP4TJVJEX1ZZJN3D6JXVCP3N2S4N4S4S4S4', name: 'Diana', tips: 850 }, - { address: 'SP5TJVJEX1ZZJN3D6JXVCP3N2S4N4S4S4S4', name: 'Eve', tips: 750 }, - ]; - - return mockUsers.map((user, index) => ({ - rank: index + 1, - address: user.address, - name: user.name, - totalTipsReceived: user.tips, - tipCount: Math.floor(user.tips / 200), - })); -} export function useDemoLeaderboard() { - const { demoEnabled } = useDemoMode(); + const { demoEnabled, demoTips } = useDemoMode(); const getLeaderboard = () => { if (!demoEnabled) return null; - return generateDemoLeaderboard(); + + const totals = new Map(); + for (const tip of demoTips) { + const amount = Number(tip.amount || 0); + const recipient = tip.recipient; + const sender = tip.sender; + + const recipientEntry = totals.get(recipient) || { + address: recipient, + name: recipient.slice(0, 8), + totalTipsReceived: 0, + tipCount: 0, + }; + recipientEntry.totalTipsReceived += amount; + recipientEntry.tipCount += 1; + totals.set(recipient, recipientEntry); + + if (!totals.has(sender)) { + totals.set(sender, { + address: sender, + name: sender.slice(0, 8), + totalTipsReceived: 0, + tipCount: 0, + }); + } + } + + return [...totals.values()] + .filter((entry) => entry.totalTipsReceived > 0) + .sort((a, b) => b.totalTipsReceived - a.totalTipsReceived) + .map((entry, index) => ({ rank: index + 1, ...entry })); }; const getRank = (address) => { if (!demoEnabled) return null; - const demo = getDemo(); - const index = demo.mockTips.findIndex(t => t.recipient === address); - return index !== -1 ? index + 1 : null; + const leaderboard = getLeaderboard(); + return leaderboard?.find((entry) => entry.address === address)?.rank || null; }; return { diff --git a/frontend/src/hooks/useDemoStats.js b/frontend/src/hooks/useDemoStats.js index 1851a0e..1c3ce09 100644 --- a/frontend/src/hooks/useDemoStats.js +++ b/frontend/src/hooks/useDemoStats.js @@ -1,23 +1,24 @@ import { useDemoMode } from '../context/DemoContext'; -import { getDemo } from '../config/demo'; export function useDemoStats() { - const { demoEnabled } = useDemoMode(); + const { demoEnabled, demoTips } = useDemoMode(); const generateDemoStats = () => { - const demo = getDemo(); - const totalAmount = demo.mockTips.reduce((sum, tip) => sum + tip.amount, 0); + const totalAmount = demoTips.reduce((sum, tip) => sum + tip.amount, 0); return { - totalTips: demo.mockTips.length, + totalTips: demoTips.length, totalAmount: totalAmount, - averageTipAmount: demo.mockTips.length > 0 ? totalAmount / demo.mockTips.length : 0, - activeTippers: new Set(demo.mockTips.map(t => t.sender)).size, - activeRecipients: new Set(demo.mockTips.map(t => t.recipient)).size, + averageTipAmount: demoTips.length > 0 ? totalAmount / demoTips.length : 0, + activeTippers: new Set(demoTips.map(t => t.sender)).size, + activeRecipients: new Set(demoTips.map(t => t.recipient)).size, platformStats: { - totalTipsOnPlatform: 1500, - totalUsersOnPlatform: 250, - averageTipSize: 100, + totalTipsOnPlatform: demoTips.length + 1498, + totalUsersOnPlatform: new Set([ + ...demoTips.map((tip) => tip.sender), + ...demoTips.map((tip) => tip.recipient), + ]).size + 248, + averageTipSize: demoTips.length > 0 ? Math.round(totalAmount / demoTips.length) : 0, }, }; }; diff --git a/frontend/src/hooks/useDemoTransaction.js b/frontend/src/hooks/useDemoTransaction.js index b0aad69..650ea74 100644 --- a/frontend/src/hooks/useDemoTransaction.js +++ b/frontend/src/hooks/useDemoTransaction.js @@ -10,7 +10,7 @@ export function useDemoTransaction() { const { demoEnabled } = useDemoMode(); const [pendingTransaction, setPendingTransaction] = useState(null); - const submitMockTransaction = useCallback(async (data) => { + const submitMockTransaction = useCallback(async (_data) => { if (!demoEnabled) { return null; } diff --git a/frontend/src/hooks/useSendTipWithDemo.js b/frontend/src/hooks/useSendTipWithDemo.js index 3661b82..3f400a5 100644 --- a/frontend/src/hooks/useSendTipWithDemo.js +++ b/frontend/src/hooks/useSendTipWithDemo.js @@ -1,9 +1,11 @@ import { useDemoMode } from '../context/DemoContext'; import { useDemoTransaction } from './useDemoTransaction'; import { useDemoBalance } from './useDemoBalance'; +import { useTipContext } from '../context/TipContext'; export function useSendTipWithDemo(realBalance) { const { demoEnabled } = useDemoMode(); + const { addDemoTip } = useTipContext(); const { submitMockTransaction, pendingTransaction } = useDemoTransaction(); const { balance: displayBalance, deductBalance } = useDemoBalance(realBalance); @@ -19,6 +21,13 @@ export function useSendTipWithDemo(realBalance) { category, }); + addDemoTip({ + recipient: recipientAddress, + amount: Math.round(parseFloat(amountSTX) * 1000000), + message, + category, + }); + return { txId: result.txId, recipient: recipientAddress, diff --git a/frontend/src/hooks/useSessionSync.test.js b/frontend/src/hooks/useSessionSync.test.js index 15d8eb4..33ed701 100644 --- a/frontend/src/hooks/useSessionSync.test.js +++ b/frontend/src/hooks/useSessionSync.test.js @@ -52,7 +52,7 @@ describe('useSessionSync', () => { }); it('detects session logout from another tab', () => { - const { rerender } = renderHook(() => useSessionSync(mockCallback)); + renderHook(() => useSessionSync(mockCallback)); // Simulate other tab logging out stacksUtils.userSession.isUserSignedIn.mockReturnValue(false); diff --git a/frontend/src/test/demo-hooks.test.js b/frontend/src/test/demo-hooks.test.js index 7ea26c3..dcee22c 100644 --- a/frontend/src/test/demo-hooks.test.js +++ b/frontend/src/test/demo-hooks.test.js @@ -1,13 +1,20 @@ +import React from 'react'; import { describe, it, expect, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { DemoProvider } from '../context/DemoContext'; import { useDemoMode } from '../context/DemoContext'; import { useDemoStats } from '../hooks/useDemoStats'; import { useDemoHistory } from '../hooks/useDemoHistory'; +import { setDemoMode } from '../config/demo'; -const wrapper = ({ children }) => {children}; +const wrapper = ({ children }) => React.createElement(DemoProvider, null, children); describe('Demo Mode Hooks', () => { + beforeEach(() => { + localStorage.clear(); + setDemoMode(false); + }); + describe('useDemoMode', () => { it('should toggle demo mode', () => { const { result } = renderHook(() => useDemoMode(), { wrapper }); @@ -39,14 +46,17 @@ describe('Demo Mode Hooks', () => { }); it('should calculate stats when demo enabled', () => { - const { result: modeResult } = renderHook(() => useDemoMode(), { wrapper }); - const { result: statsResult } = renderHook(() => useDemoStats(), { wrapper }); - + const { result } = renderHook(() => { + const mode = useDemoMode(); + const stats = useDemoStats(); + return { mode, stats }; + }, { wrapper }); + act(() => { - modeResult.current.toggleDemo(true); + result.current.mode.toggleDemo(true); }); - - const stats = statsResult.current.getDemoStats(); + + const stats = result.current.stats.getDemoStats(); expect(stats).toBeDefined(); expect(stats.totalTips).toBeGreaterThanOrEqual(0); expect(stats.platformStats).toBeDefined(); diff --git a/vitest.config.js b/vitest.config.js index ed1b59f..e059b7f 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -23,10 +23,9 @@ export default defineConfig({ test: { include: ["tests/**/*.test.ts"], environment: "clarinet", // use vitest-environment-clarinet - pool: "forks", + pool: "threads", poolOptions: { threads: { singleThread: true }, - forks: { singleFork: true }, }, setupFiles: [ vitestSetupFilePath, @@ -40,4 +39,3 @@ export default defineConfig({ }, }, }); -