Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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());
Expand All @@ -48,6 +54,11 @@ function App() {
return;
}

// Exit demo mode when connecting a real wallet
if (isDemo) {
exitDemo();
}

setAuthLoading(true);
try {
await authenticate();
Expand All @@ -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: '👤' },
Expand All @@ -74,19 +90,21 @@ function App() {

return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-gray-950 transition-colors">
<DemoBanner />
<OfflineBanner />
<Header
userData={userData}
onAuth={handleAuth}
authLoading={authLoading}
notifications={notifications}
unreadCount={unreadCount}
notifications={isDemo ? [] : notifications}
unreadCount={isDemo ? 0 : unreadCount}
onMarkNotificationsRead={markAllRead}
notificationsLoading={notificationsLoading}
notificationsLoading={isDemo ? false : notificationsLoading}
isDemo={isDemo}
/>

<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{userData ? (
{isAuthenticated ? (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
<Onboarding />
<nav className="mb-16 -mx-4 sm:mx-0">
Expand Down Expand Up @@ -132,7 +150,7 @@ function App() {
>
<Routes>
<Route path="/send" element={<SendTip addToast={addToast} />} />
<Route path="/activity" element={<TipHistory userAddress={userData.profile.stxAddress.mainnet} />} />
<Route path="/activity" element={<TipHistory userAddress={isDemo ? 'SP1DEMO000000000000000000000SANDBOX' : userData.profile.stxAddress.mainnet} />} />
<Route path="/feed" element={<RecentTips addToast={addToast} />} />
<Route path="/stats" element={<PlatformStats />} />
<Route path="/leaderboard" element={<Leaderboard />} />
Expand All @@ -145,7 +163,7 @@ function App() {
</Suspense>
</div>
) : (
<AnimatedHero onGetStarted={handleAuth} loading={authLoading} />
<AnimatedHero onGetStarted={handleAuth} onTryDemo={handleTryDemo} loading={authLoading} />
)}
</main>

Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/DemoBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useDemoMode } from '../context/DemoContext';

export default function DemoBanner() {
const { isDemo, exitDemo } = useDemoMode();

if (!isDemo) return null;

return (
<div className="bg-amber-500 text-amber-950 text-center py-2 px-4 text-sm font-semibold flex items-center justify-center gap-3 z-50 relative">
<span className="inline-flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Demo Mode — No real STX is being used. Transactions are simulated.
</span>
<button
onClick={exitDemo}
className="ml-2 px-3 py-0.5 bg-amber-950 text-amber-100 rounded-full text-xs font-bold hover:bg-amber-900 transition-colors"
>
Exit Demo
</button>
</div>
);
}
13 changes: 11 additions & 2 deletions frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -74,6 +74,15 @@ export default function Header({ userData, onAuth, authLoading, notifications, u
/>
)}

{isDemo && !userData && (
<div className="hidden sm:flex flex-col items-end">
<span className="text-[10px] font-bold text-amber-400 uppercase tracking-tighter">Demo Wallet</span>
<p className="text-xs font-mono text-white/90 bg-amber-500/20 px-2 py-1 rounded-lg border border-amber-500/30 truncate max-w-[140px] sm:max-w-none">
SP1DEMO...SANDBOX
</p>
</div>
)}

{userData && (
<div className="hidden sm:flex flex-col items-end">
<span className="text-[10px] font-bold text-gray-300 uppercase tracking-tighter">Connected Wallet</span>
Expand All @@ -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'}
</button>
</div>
</div>
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/components/Leaderboard.jsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -61,7 +74,7 @@
setError(err.message || 'Failed to load leaderboard');
setLoading(false);
}
}, []);

Check warning on line 77 in frontend/src/components/Leaderboard.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

React Hook useCallback has missing dependencies: 'demoLeaderboard' and 'isDemo'. Either include them or remove the dependency array

useEffect(() => {
fetchLeaderboard();
Expand Down
14 changes: 11 additions & 3 deletions frontend/src/components/PlatformStats.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,9 +96,9 @@ export default function PlatformStats() {
>
Refresh
</button>
<div className="bg-green-100 text-green-700 px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest flex items-center">
<span className="h-2 w-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
Live
<div className={`${isDemo ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'} px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest flex items-center`}>
<span className={`h-2 w-2 ${isDemo ? 'bg-amber-500' : 'bg-green-500'} rounded-full mr-2 animate-pulse`}></span>
{isDemo ? 'Demo' : 'Live'}
</div>
</div>
</div>
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/RecentTips.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
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';
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);
Expand All @@ -26,9 +28,23 @@
const [sortBy, setSortBy] = useState('newest');
const [showFilters, setShowFilters] = useState(false);
const [offset, setOffset] = useState(0);
const [totalResults, setTotalResults] = useState(0);

Check failure on line 31 in frontend/src/components/RecentTips.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'totalResults' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u

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}`;
Expand Down Expand Up @@ -63,7 +79,7 @@
);
setLoading(false);
}
}, []);

Check warning on line 82 in frontend/src/components/RecentTips.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

React Hook useCallback has missing dependencies: 'demoTips' and 'isDemo'. Either include them or remove the dependency array

useEffect(() => {
fetchRecentTips();
Expand Down
40 changes: 35 additions & 5 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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('');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/TipHistory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand All @@ -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([
Expand Down
Loading
Loading