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
52 changes: 39 additions & 13 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,21 +8,23 @@ 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';
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,
ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY,
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'));
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
Expand All @@ -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;
Expand All @@ -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 (
Expand All @@ -167,6 +192,7 @@ function App() {
userData={userData}
onAuth={handleAuth}
authLoading={authLoading}
demoEnabled={demoEnabled}
notifications={notifications}
unreadCount={unreadCount}
lastSeenTimestamp={lastSeenTimestamp}
Expand All @@ -177,9 +203,9 @@ function App() {

<main id="main-content" tabIndex={-1} className="flex-1">
{/* Show landing hero only if user has not connected AND is on home route */}
{!userData && location.pathname === '/' ? (
{!userData && !demoEnabled && location.pathname === '/' ? (
<Suspense fallback={<div className="min-h-[85vh] bg-black" />}>
<AnimatedHero onGetStarted={handleAuth} loading={authLoading} />
<AnimatedHero onGetStarted={handleAuth} onTryDemo={handleTryDemo} loading={authLoading} demoLoading={demoLoading} />
</Suspense>
) : (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 animate-fade-in-up">
Expand Down Expand Up @@ -223,7 +249,7 @@ function App() {
<Route
path={ROUTE_SEND}
element={
userData ? (
userData || demoEnabled ? (
<SendTip addToast={addToast} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_SEND}>
Expand All @@ -235,7 +261,7 @@ function App() {
<Route
path={ROUTE_BATCH}
element={
userData ? (
userData || demoEnabled ? (
<BatchTip addToast={addToast} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_BATCH}>
Expand All @@ -247,7 +273,7 @@ function App() {
<Route
path={ROUTE_TOKEN_TIP}
element={
userData ? (
userData || demoEnabled ? (
<TokenTip addToast={addToast} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_TOKEN_TIP}>
Expand All @@ -266,7 +292,7 @@ function App() {
<Route
path={ROUTE_ACTIVITY}
element={
userData ? (
userData || demoEnabled ? (
<TipHistory userAddress={userAddress} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_ACTIVITY}>
Expand All @@ -278,7 +304,7 @@ function App() {
<Route
path={ROUTE_PROFILE}
element={
userData ? (
userData || demoEnabled ? (
<ProfileManager addToast={addToast} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_PROFILE}>
Expand All @@ -290,7 +316,7 @@ function App() {
<Route
path={ROUTE_BLOCK}
element={
userData ? (
userData || demoEnabled ? (
<BlockManager addToast={addToast} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_BLOCK}>
Expand All @@ -305,7 +331,7 @@ function App() {
<Route path={ROUTE_TELEMETRY} element={<RequireAdmin><TelemetryDashboard addToast={addToast} /></RequireAdmin>} />

{/* Root and fallback */}
<Route path="/" element={<Navigate to={userData ? DEFAULT_AUTHENTICATED_ROUTE : ROUTE_FEED} replace />} />
<Route path="/" element={<Navigate to={userData || demoEnabled ? DEFAULT_AUTHENTICATED_ROUTE : ROUTE_FEED} replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -53,6 +54,11 @@ export default function Header({ userData, onAuth, authLoading, notifications, u
/>
<div className="flex items-center gap-2">
<h1 className="text-lg font-black text-white tracking-tight">TipStream</h1>
{demoEnabled && (
<span className="hidden sm:inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-200">
Demo
</span>
)}
<div className="hidden sm:flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-white/5 border border-white/5" role="status" aria-label={`API status: ${apiReachable === null ? 'checking' : apiReachable ? 'connected' : 'disconnected'}`}>
<span
className={`h-1.5 w-1.5 rounded-full ${apiReachable === null ? 'bg-yellow-400 animate-pulse' : apiReachable ? 'bg-green-400 pulse-live' : 'bg-red-400'}`}
Expand Down Expand Up @@ -116,7 +122,7 @@ export default function Header({ userData, onAuth, authLoading, notifications, u
: 'bg-gradient-to-r from-amber-500 to-orange-500 text-black hover:shadow-lg hover:shadow-amber-500/20'
}`}
>
{authLoading ? 'Connecting...' : userData ? 'Disconnect' : 'Connect Wallet'}
{authLoading ? 'Connecting...' : demoEnabled ? 'Exit Demo' : userData ? 'Disconnect' : 'Connect Wallet'}
</button>
</div>
</div>
Expand Down
35 changes: 30 additions & 5 deletions frontend/src/components/PlatformStats.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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());
Expand Down Expand Up @@ -66,16 +84,23 @@ export default function PlatformStats() {
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Global Impact</h2>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Global Impact</h2>
{demoEnabled && (
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-200">
Demo
</span>
)}
</div>
<div className="flex items-center gap-3">
{lastRefresh && <span className="text-xs text-gray-400">{lastRefresh.toLocaleTimeString()}</span>}
<button onClick={fetchPlatformStats}
className="px-3 py-1.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
Refresh
</button>
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-[10px] font-bold uppercase tracking-wider">
<span className="h-1.5 w-1.5 bg-green-500 rounded-full animate-pulse" />Live
</span>
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full text-[10px] font-bold uppercase tracking-wider">
<span className="h-1.5 w-1.5 bg-green-500 rounded-full animate-pulse" />Live
</span>
</div>
</div>

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/RecentTips.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,7 +24,9 @@ export default function RecentTips({ addToast }) {
lastEventRefresh,
refreshEvents,
loadMoreEvents: contextLoadMore,
addDemoTip,
} = useTipContext();
const { demoEnabled, setDemoBalance } = useDemoMode();

const {
enrichedTips: allEnrichedTips,
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/RequireAuth.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="max-w-2xl mx-auto space-y-8">
{children}
Expand Down
Loading
Loading