From 7d3ca4036cff0275ede4042eb9e6c7585645f3a2 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 27 Feb 2026 20:25:42 +0100 Subject: [PATCH] feat: add demo/sandbox mode for risk-free platform exploration Implement a complete demo mode allowing users to try TipStream without connecting a wallet or spending real STX. This lowers the barrier for new users and allows hackathon judges to experience full functionality. New files: - DemoContext.jsx: Provider with mock data, simulated tip sending, demo platform stats, demo leaderboard, and demo balance - DemoBanner.jsx: Amber banner indicating demo mode is active with an exit button to switch back to live mode Changes: - AnimatedHero: Replace 'Learn More' with 'Try Demo' button and add 'No wallet needed' hint text - App.jsx: Wire demo context, add handleTryDemo handler, show DemoBanner, treat demo mode as authenticated state - main.jsx: Wrap app in DemoProvider - Header: Show demo wallet indicator and 'Connect Real Wallet' button text when in demo mode - SendTip: Simulate tip sending with fake tx IDs and 800ms delay, use demo balance instead of real balance in demo mode - PlatformStats: Use mock stats data in demo mode, show Demo/Live badge indicator badge indicator se mock stats data in din demo mode - RecentTips: Use mock tip feed data in demo mode - TipHistory: Use mock user stats and tip history in demo mode Closes #98 --- frontend/src/App.jsx | 30 ++++- frontend/src/components/DemoBanner.jsx | 24 ++++ frontend/src/components/Header.jsx | 13 +- frontend/src/components/Leaderboard.jsx | 13 ++ frontend/src/components/PlatformStats.jsx | 14 +- frontend/src/components/RecentTips.jsx | 16 +++ frontend/src/components/SendTip.jsx | 40 +++++- frontend/src/components/TipHistory.jsx | 26 ++++ frontend/src/components/ui/animated-hero.jsx | 12 +- frontend/src/context/DemoContext.jsx | 127 +++++++++++++++++++ frontend/src/main.jsx | 9 +- 11 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/DemoBanner.jsx create mode 100644 frontend/src/context/DemoContext.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f5275d05..d7eb7863 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,10 +5,12 @@ import Header from './components/Header'; import SendTip from './components/SendTip'; import OfflineBanner from './components/OfflineBanner'; import Onboarding from './components/Onboarding'; +import DemoBanner from './components/DemoBanner'; import { AnimatedHero } from './components/ui/animated-hero'; import { ToastContainer, useToast } from './components/ui/toast'; import { analytics } from './lib/analytics'; import { useNotifications } from './hooks/useNotifications'; +import { useDemoMode } from './context/DemoContext'; const TipHistory = lazy(() => import('./components/TipHistory')); const PlatformStats = lazy(() => import('./components/PlatformStats')); @@ -24,10 +26,14 @@ function App() { const [authLoading, setAuthLoading] = useState(false); const { toasts, addToast, removeToast } = useToast(); const location = useLocation(); + const { isDemo, enterDemo, exitDemo } = useDemoMode(); const userAddress = userData?.profile?.stxAddress?.mainnet || null; const { notifications, unreadCount, markAllRead, loading: notificationsLoading } = useNotifications(userAddress); + // Treat app as "authenticated" when either really signed in or in demo mode + const isAuthenticated = !!userData || isDemo; + useEffect(() => { if (userSession.isUserSignedIn()) { setUserData(userSession.loadUserData()); @@ -48,6 +54,11 @@ function App() { return; } + // Exit demo mode when connecting a real wallet + if (isDemo) { + exitDemo(); + } + setAuthLoading(true); try { await authenticate(); @@ -60,6 +71,11 @@ function App() { } }; + const handleTryDemo = () => { + enterDemo(); + addToast('Welcome to demo mode! Explore the platform with simulated data.', 'info'); + }; + const navItems = [ { path: '/send', label: 'Send Tip', icon: '⚡' }, { path: '/activity', label: 'My Activity', icon: '👤' }, @@ -74,19 +90,21 @@ function App() { return (
+
- {userData ? ( + {isAuthenticated ? (
) : ( - + )}
diff --git a/frontend/src/components/DemoBanner.jsx b/frontend/src/components/DemoBanner.jsx new file mode 100644 index 00000000..0f50d531 --- /dev/null +++ b/frontend/src/components/DemoBanner.jsx @@ -0,0 +1,24 @@ +import { useDemoMode } from '../context/DemoContext'; + +export default function DemoBanner() { + const { isDemo, exitDemo } = useDemoMode(); + + if (!isDemo) return null; + + return ( +
+ + + + + Demo Mode — No real STX is being used. Transactions are simulated. + + +
+ ); +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index c2210aa4..826064c2 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -4,7 +4,7 @@ import NotificationBell from './NotificationBell'; import { useTheme } from '../context/ThemeContext'; import { NETWORK_NAME, STACKS_API_BASE } from '../config/contracts'; -export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading }) { +export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading, isDemo }) { const { theme, toggleTheme } = useTheme(); const [apiReachable, setApiReachable] = useState(null); @@ -74,6 +74,15 @@ export default function Header({ userData, onAuth, authLoading, notifications, u /> )} + {isDemo && !userData && ( +
+ Demo Wallet +

+ SP1DEMO...SANDBOX +

+
+ )} + {userData && (
Connected Wallet @@ -95,7 +104,7 @@ export default function Header({ userData, onAuth, authLoading, notifications, u : 'bg-white text-gray-900 hover:bg-gray-50 hover:shadow-white/10' }`} > - {authLoading ? 'Connecting...' : userData ? 'Disconnect' : 'Connect Wallet'} + {authLoading ? 'Connecting...' : userData ? 'Disconnect' : isDemo ? 'Connect Real Wallet' : 'Connect Wallet'}
diff --git a/frontend/src/components/Leaderboard.jsx b/frontend/src/components/Leaderboard.jsx index c3f59282..33599d60 100644 --- a/frontend/src/components/Leaderboard.jsx +++ b/frontend/src/components/Leaderboard.jsx @@ -1,17 +1,30 @@ import { useEffect, useState, useCallback } from 'react'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; import { formatSTX, formatAddress } from '../lib/utils'; +import { useDemoMode } from '../context/DemoContext'; import CopyButton from './ui/copy-button'; const API_BASE = 'https://api.hiro.so'; export default function Leaderboard() { + const { isDemo, demoLeaderboard } = useDemoMode(); const [leaders, setLeaders] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tab, setTab] = useState('sent'); const fetchLeaderboard = useCallback(async () => { + if (isDemo) { + setLeaders(demoLeaderboard.map(l => ({ + address: l.address, + totalSent: l.totalSent, + tipsSent: l.tipCount, + totalReceived: Math.floor(l.totalSent * 0.7), + tipsReceived: Math.floor(l.tipCount * 0.6), + }))); + setLoading(false); + return; + } try { setLoading(true); setError(null); diff --git a/frontend/src/components/PlatformStats.jsx b/frontend/src/components/PlatformStats.jsx index 0723fde4..ff9ea7b5 100644 --- a/frontend/src/components/PlatformStats.jsx +++ b/frontend/src/components/PlatformStats.jsx @@ -4,15 +4,23 @@ import { network } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; import { formatSTX } from '../lib/utils'; import { useTipContext } from '../context/TipContext'; +import { useDemoMode } from '../context/DemoContext'; export default function PlatformStats() { const { refreshCounter } = useTipContext(); + const { isDemo, demoPlatformStats } = useDemoMode(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const fetchPlatformStats = useCallback(async () => { + if (isDemo) { + setStats(demoPlatformStats); + setLoading(false); + setLastRefresh(new Date()); + return; + } try { const result = await fetchCallReadOnlyFunction({ network, @@ -88,9 +96,9 @@ export default function PlatformStats() { > Refresh -
- - Live +
+ + {isDemo ? 'Demo' : 'Live'}
diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx index 683d80a3..360101a0 100644 --- a/frontend/src/components/RecentTips.jsx +++ b/frontend/src/components/RecentTips.jsx @@ -5,6 +5,7 @@ import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; import { formatSTX, toMicroSTX, formatAddress } from '../lib/utils'; import { network, appDetails, userSession } from '../utils/stacks'; import { useTipContext } from '../context/TipContext'; +import { useDemoMode } from '../context/DemoContext'; import CopyButton from './ui/copy-button'; const API_BASE = 'https://api.hiro.so'; @@ -12,6 +13,7 @@ const PAGE_SIZE = 10; export default function RecentTips({ addToast }) { const { refreshCounter } = useTipContext(); + const { isDemo, demoTips } = useDemoMode(); const [tips, setTips] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -29,6 +31,20 @@ export default function RecentTips({ addToast }) { const [totalResults, setTotalResults] = useState(0); const fetchRecentTips = useCallback(async () => { + if (isDemo) { + const mapped = demoTips.map(t => ({ + event: 'tip-sent', + sender: t.sender, + recipient: t.recipient, + amount: t.amount, + message: t.message, + })); + setTips(mapped); + setTotalResults(mapped.length); + setLoading(false); + setLastRefresh(new Date()); + return; + } try { setError(null); const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; diff --git a/frontend/src/components/SendTip.jsx b/frontend/src/components/SendTip.jsx index a5a92618..e6872041 100644 --- a/frontend/src/components/SendTip.jsx +++ b/frontend/src/components/SendTip.jsx @@ -14,6 +14,7 @@ import { useTipContext } from '../context/TipContext'; import { useBalance } from '../hooks/useBalance'; import { useStxPrice } from '../hooks/useStxPrice'; import { analytics } from '../lib/analytics'; +import { useDemoMode } from '../context/DemoContext'; import ConfirmDialog from './ui/confirm-dialog'; import TxStatus from './ui/tx-status'; @@ -36,6 +37,7 @@ const TIP_CATEGORIES = [ export default function SendTip({ addToast }) { const { notifyTipSent } = useTipContext(); const { toUsd } = useStxPrice(); + const { isDemo, simulateTipSend, demoBalance } = useDemoMode(); const [recipient, setRecipient] = useState(''); const [amount, setAmount] = useState(''); const [message, setMessage] = useState(''); @@ -69,16 +71,19 @@ export default function SendTip({ addToast }) { }, []); const senderAddress = useMemo(() => { + if (isDemo) return 'SP1DEMO000000000000000000000SANDBOX'; try { return userSession.loadUserData().profile.stxAddress.mainnet; } catch { return null; } - }, []); + }, [isDemo]); - const { balance, loading: balanceLoading, refetch: refetchBalance } = useBalance(senderAddress); + const { balance, loading: balanceLoading, refetch: refetchBalance } = useBalance(isDemo ? null : senderAddress); - const balanceSTX = balance !== null ? Number(balance) / 1_000_000 : null; + const balanceSTX = isDemo + ? demoBalance / 1_000_000 + : balance !== null ? Number(balance) / 1_000_000 : null; const isValidStacksAddress = (address) => { if (!address) return false; @@ -132,8 +137,8 @@ export default function SendTip({ addToast }) { return; } - const senderAddress = userSession.loadUserData().profile.stxAddress.mainnet; - if (recipient.trim() === senderAddress) { + const currentSender = isDemo ? 'SP1DEMO000000000000000000000SANDBOX' : userSession.loadUserData().profile.stxAddress.mainnet; + if (recipient.trim() === currentSender) { addToast('You cannot send a tip to yourself', 'warning'); return; } @@ -169,6 +174,31 @@ export default function SendTip({ addToast }) { setLoading(true); + // Demo mode: simulate tip sending + if (isDemo) { + await new Promise(resolve => setTimeout(resolve, 800)); // Simulate network delay + const result = simulateTipSend({ + recipient: recipient.trim(), + amount: toMicroSTX(amount), + message: message || 'Thanks!', + category, + }); + setLoading(false); + setPendingTx({ + txId: result.txId, + recipient, + amount: parseFloat(amount), + }); + setRecipient(''); + setAmount(''); + setMessage(''); + setCategory(0); + notifyTipSent(); + startCooldown(); + addToast('Demo tip sent! (simulated — no real STX used)', 'success'); + return; + } + try { const microSTX = toMicroSTX(amount); const senderAddress = userSession.loadUserData().profile.stxAddress.mainnet; diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx index 519a9812..9fd5bc8f 100644 --- a/frontend/src/components/TipHistory.jsx +++ b/frontend/src/components/TipHistory.jsx @@ -4,6 +4,7 @@ import { network } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; import { formatSTX, formatAddress } from '../lib/utils'; import { useTipContext } from '../context/TipContext'; +import { useDemoMode } from '../context/DemoContext'; import CopyButton from './ui/copy-button'; import ShareTip from './ShareTip'; @@ -21,6 +22,7 @@ const CATEGORY_LABELS = { export default function TipHistory({ userAddress }) { const { refreshCounter } = useTipContext(); + const { isDemo, demoTips } = useDemoMode(); const [stats, setStats] = useState(null); const [tips, setTips] = useState([]); const [loading, setLoading] = useState(true); @@ -31,6 +33,30 @@ export default function TipHistory({ userAddress }) { const fetchData = useCallback(async () => { if (!userAddress) return; + + // Demo mode: use mock data + if (isDemo) { + const demoStats = { + 'tips-sent': { value: '24' }, + 'tips-received': { value: '18' }, + 'total-sent': { value: '156000000' }, + 'total-received': { value: '89000000' }, + }; + setStats(demoStats); + const mapped = demoTips.map(t => ({ + event: 'tip-sent', + sender: t.sender, + recipient: t.recipient, + amount: t.amount, + message: t.message, + category: t.category, + })); + setTips(mapped); + setLoading(false); + setLastRefresh(new Date()); + return; + } + try { setError(null); const [statsResult, tipsResult] = await Promise.all([ diff --git a/frontend/src/components/ui/animated-hero.jsx b/frontend/src/components/ui/animated-hero.jsx index 53f93c10..fd4c722f 100644 --- a/frontend/src/components/ui/animated-hero.jsx +++ b/frontend/src/components/ui/animated-hero.jsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { motion } from "framer-motion"; -import { MoveRight, Zap } from "lucide-react"; +import { MoveRight, Zap, Play } from "lucide-react"; import { Button } from "@/components/ui/button"; // Animation configuration for the rotating title text @@ -8,7 +8,7 @@ const TITLE_ROTATION_INTERVAL_MS = 2000; const TITLE_SLIDE_OFFSET_PX = 150; const SPRING_STIFFNESS = 50; -function AnimatedHero({ onGetStarted }) { +function AnimatedHero({ onGetStarted, onTryDemo, loading }) { const [titleNumber, setTitleNumber] = useState(0); const titles = useMemo( () => ["instant", "secure", "transparent", "effortless", "powerful"], @@ -73,6 +73,7 @@ function AnimatedHero({ onGetStarted }) { size="lg" className="gap-4 bg-slate-900 hover:bg-slate-800 text-white shadow-2xl hover:shadow-slate-200 transition-all transform hover:-translate-y-1 active:scale-95" onClick={onGetStarted} + disabled={loading} > Get Started Now @@ -80,11 +81,14 @@ function AnimatedHero({ onGetStarted }) { size="lg" className="gap-4" variant="outline" - onClick={() => window.open('https://github.com/Mosas2000/TipStream#readme', '_blank', 'noopener')} + onClick={onTryDemo} > - Learn More + Try Demo +

+ No wallet needed for demo mode +

diff --git a/frontend/src/context/DemoContext.jsx b/frontend/src/context/DemoContext.jsx new file mode 100644 index 00000000..6ee5308a --- /dev/null +++ b/frontend/src/context/DemoContext.jsx @@ -0,0 +1,127 @@ +import { createContext, useContext, useState, useCallback, useMemo } from 'react'; + +const DemoContext = createContext(null); + +// Realistic mock addresses +const DEMO_ADDRESSES = [ + 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + 'SP000000000000000000002Q6VF78', + 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE', + 'SPNWZ5V2TPWGQGVDR6T7B6RQ4XMGZ4PXTEE0VQ0S', + 'SP2C2YFP12AJZB1MATRSD34F5Z6KNPZMC2P3RCXGE', +]; + +const DEMO_USER_ADDRESS = 'SP1DEMO000000000000000000000SANDBOX'; + +const DEMO_CATEGORIES = ['General', 'Content Creation', 'Open Source', 'Community Help', 'Appreciation', 'Education', 'Bug Bounty']; + +function generateDemoTips(count = 15) { + const messages = [ + 'Great article on Stacks development!', + 'Thanks for the open source contribution!', + 'Loved your tutorial on Clarity', + 'Awesome community support', + 'Keep up the great work!', + 'Your documentation was super helpful', + 'Bug fix saved my project!', + 'Really enjoyed the livestream', + 'Best explanation of post-conditions ever', + 'Incredible smart contract work', + 'Your NFT marketplace tutorial was perfect', + 'Thanks for answering my question', + 'Great podcast episode!', + 'Your code review was thorough', + 'Amazing contribution to the ecosystem', + ]; + + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + sender: DEMO_ADDRESSES[i % DEMO_ADDRESSES.length], + recipient: DEMO_ADDRESSES[(i + 1) % DEMO_ADDRESSES.length], + amount: Math.floor(Math.random() * 50_000_000) + 100_000, // 0.1 - 50 STX in microSTX + message: messages[i % messages.length], + category: i % DEMO_CATEGORIES.length, + categoryLabel: DEMO_CATEGORIES[i % DEMO_CATEGORIES.length], + blockHeight: 180000 - i * 12, + timestamp: Date.now() - i * 3600_000, // 1 hour apart + })); +} + +const DEMO_PLATFORM_STATS = { + 'total-tips': { value: '1247' }, + 'total-volume': { value: '8439000000' }, // 8,439 STX + 'platform-fees': { value: '42195000' }, // ~42 STX + 'unique-tippers': { value: '312' }, +}; + +const DEMO_LEADERBOARD = DEMO_ADDRESSES.map((addr, i) => ({ + address: addr, + totalSent: (500 - i * 80) * 1_000_000, + tipCount: 50 - i * 8, +})); + +const DEMO_BALANCE = 125_500_000; // 125.5 STX + +let demoTxCounter = 0; + +export function DemoProvider({ children }) { + const [isDemo, setIsDemo] = useState(false); + const [demoTips, setDemoTips] = useState(() => generateDemoTips()); + const [demoNotifications, setDemoNotifications] = useState([]); + + const enterDemo = useCallback(() => { + setIsDemo(true); + setDemoTips(generateDemoTips()); + setDemoNotifications([]); + demoTxCounter = 0; + }, []); + + const exitDemo = useCallback(() => { + setIsDemo(false); + }, []); + + const simulateTipSend = useCallback(({ recipient, amount, message, category }) => { + demoTxCounter += 1; + const fakeTxId = `0xdemo${demoTxCounter.toString().padStart(6, '0')}${'a'.repeat(54)}`; + const newTip = { + id: Date.now(), + sender: DEMO_USER_ADDRESS, + recipient, + amount, + message: message || 'Thanks!', + category, + categoryLabel: DEMO_CATEGORIES[category] || 'General', + blockHeight: 180000 + demoTxCounter, + timestamp: Date.now(), + }; + setDemoTips(prev => [newTip, ...prev]); + return { txId: fakeTxId, success: true }; + }, []); + + const value = useMemo(() => ({ + isDemo, + enterDemo, + exitDemo, + demoTips, + demoNotifications, + simulateTipSend, + demoUserAddress: DEMO_USER_ADDRESS, + demoBalance: DEMO_BALANCE, + demoPlatformStats: DEMO_PLATFORM_STATS, + demoLeaderboard: DEMO_LEADERBOARD, + }), [isDemo, enterDemo, exitDemo, demoTips, demoNotifications, simulateTipSend]); + + return ( + + {children} + + ); +} + +export function useDemoMode() { + const context = useContext(DemoContext); + if (!context) { + throw new Error('useDemoMode must be used within a DemoProvider'); + } + return context; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 1df28e9a..644e487e 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -6,15 +6,18 @@ import App from './App.jsx' import ErrorBoundary from './components/ErrorBoundary.jsx' import { TipProvider } from './context/TipContext.jsx' import { ThemeProvider } from './context/ThemeContext.jsx' +import { DemoProvider } from './context/DemoContext.jsx' createRoot(document.getElementById('root')).render( - - - + + + + +