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
5 changes: 4 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@
"@types/express": "^5.0.6",
"@types/node": "^25.2.3",
"@types/pg": "^8.16.0",
"@types/supertest": "^7.2.0",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"nodemon": "^3.1.11",
"prisma": "^7.4.1",
"supertest": "^7.2.2",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}
28 changes: 28 additions & 0 deletions backend/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,31 @@ export const getUser = async (req: Request, res: Response, next: NextFunction) =
next(error);
}
};

/**
* Get user events (history)
*/
export const getUserEvents = async (req: Request, res: Response, next: NextFunction) => {
try {
const { publicKey } = req.params;

const events = await prisma.streamEvent.findMany({
where: {
stream: {
OR: [
{ sender: publicKey },
{ recipient: publicKey }
]
}
},
orderBy: { timestamp: 'desc' },
include: {
stream: true
}
});

return res.status(200).json(events);
} catch (error) {
next(error);
}
};
31 changes: 30 additions & 1 deletion backend/src/routes/v1/user.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router } from 'express';
import { registerUser, getUser } from '../../controllers/user.controller.js';
import { registerUser, getUser, getUserEvents } from '../../controllers/user.controller.js';

const router = Router();

Expand Down Expand Up @@ -66,4 +66,33 @@ const router = Router();
router.post('/', registerUser);
router.get('/:publicKey', getUser);

/**
* @openapi
* /v1/users/{publicKey}/events:
* get:
* tags:
* - Users
* summary: Fetch user activity history
* description: Returns a chronological history of all stream events associated with the user.
* parameters:
* - in: path
* name: publicKey
* required: true
* schema:
* type: string
* description: Stellar public key
* responses:
* 200:
* description: List of user events
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/StreamEvent'
* 404:
* description: User not found
*/
router.get('/:publicKey/events', getUserEvents);

export default router;
149 changes: 97 additions & 52 deletions frontend/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import { downloadCSV } from '../utils/csvExport';
import { ActivityHistory } from './dashboard/ActivityHistory';
import { fetchUserEvents } from '@/lib/dashboard';
import { useWallet } from '@/context/wallet-context';
import { BackendStreamEvent } from '@/lib/api-types';

interface StreamData extends Record<string, unknown> {
id: string;
Expand All @@ -21,6 +23,30 @@ const mockStreams: StreamData[] = [
];

const Dashboard: React.FC = () => {
const { session } = useWallet();
const [activeTab, setActiveTab] = React.useState<'streams' | 'activity'>('streams');
const [events, setEvents] = React.useState<BackendStreamEvent[]>([]);
const [isLoadingEvents, setIsLoadingEvents] = React.useState(false);

React.useEffect(() => {
if (activeTab === 'activity' && session?.publicKey) {
loadEvents();
}
}, [activeTab, session?.publicKey]);

const loadEvents = async () => {
if (!session?.publicKey) return;
setIsLoadingEvents(true);
try {
const data = await fetchUserEvents(session.publicKey);
setEvents(data);
} catch (error) {
console.error(error);
} finally {
setIsLoadingEvents(false);
}
};

const handleExport = () => {
downloadCSV(mockStreams, 'flowfi-stream-history.csv');
};
Expand All @@ -37,59 +63,78 @@ const Dashboard: React.FC = () => {
return (
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Stream History</h1>
<button
onClick={handleExport}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded shadow transition-colors"
>
Export CSV
</button>
<div className="flex items-center gap-6">
<button
onClick={() => setActiveTab('streams')}
className={`text-2xl font-bold transition-colors ${activeTab === 'streams' ? 'text-gray-800 dark:text-white' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'}`}
>
Stream History
</button>
<button
onClick={() => setActiveTab('activity')}
className={`text-2xl font-bold transition-colors ${activeTab === 'activity' ? 'text-gray-800 dark:text-white' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'}`}
>
Activity
</button>
</div>
{activeTab === 'streams' && (
<button
onClick={handleExport}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded shadow transition-colors"
>
Export CSV
</button>
)}
</div>

<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow rounded-lg">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recipient</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deposited</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Withdrawn</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Token</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{mockStreams.map((stream) => (
<tr key={stream.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.date}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">{stream.recipient}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-semibold">{stream.deposited} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.withdrawn} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${stream.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
stream.status === 'Completed' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
{stream.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{stream.status === 'Active' && (
<button
onClick={() => handleTopUp(stream.id)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 bg-green-50 dark:bg-green-900/20 px-3 py-1 rounded-md transition-colors font-semibold"
>
Add Funds
</button>
)}
</td>
{activeTab === 'streams' ? (
<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow rounded-lg">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recipient</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deposited</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Withdrawn</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Token</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{mockStreams.map((stream) => (
<tr key={stream.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.date}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">{stream.recipient}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-semibold">{stream.deposited} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.withdrawn} {stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.token}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
${stream.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
stream.status === 'Completed' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
{stream.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{stream.status === 'Active' && (
<button
onClick={() => handleTopUp(stream.id)}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 bg-green-50 dark:bg-green-900/20 px-3 py-1 rounded-md transition-colors font-semibold"
>
Add Funds
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<ActivityHistory events={events} isLoading={isLoadingEvents} />
)}
</div>
);
};
Expand Down
8 changes: 7 additions & 1 deletion frontend/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React from "react";
import { NotificationDropdown } from "./NotificationDropdown";
import { useWallet } from "@/context/wallet-context";
import { Button } from "./ui/Button";
import { ModeToggle } from "./ModeToggle";
import { WalletButton } from "./wallet/WalletButton";

export const Navbar = () => {
const { session, status } = useWallet();

return (
<nav className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 backdrop-blur-md md:px-12 border-b border-glass-border bg-background/50">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -44,6 +47,9 @@ export const Navbar = () => {
</div>

<div className="flex items-center gap-4">
{status === "connected" && session?.publicKey && (
<NotificationDropdown publicKey={session.publicKey} />
)}
<Button variant="ghost" className="hidden sm:inline-flex">
Log In
</Button>
Expand Down
102 changes: 102 additions & 0 deletions frontend/components/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { useState, useEffect } from 'react';
import { BackendStreamEvent } from '@/lib/api-types';
import { fetchUserEvents } from '@/lib/dashboard';
import { Button } from './ui/Button';

interface NotificationDropdownProps {
publicKey: string;
}

export const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ publicKey }) => {
const [isOpen, setIsOpen] = useState(false);
const [events, setEvents] = useState<BackendStreamEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
if (isOpen && publicKey) {
loadEvents();
}
}, [isOpen, publicKey]);

const loadEvents = async () => {
setIsLoading(true);
try {
const data = await fetchUserEvents(publicKey);
setEvents(data.slice(0, 5)); // Show only last 5
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};

const formatEventMessage = (event: BackendStreamEvent) => {
const amount = event.amount ? parseFloat(event.amount) / 1e7 : 0;
switch (event.eventType) {
case 'CREATED': return `New stream #${event.streamId}`;
case 'TOPPED_UP': return `Topped up #${event.streamId}`;
case 'WITHDRAWN': return `Withdrew ${amount} from #${event.streamId}`;
case 'CANCELLED': return `Cancelled #${event.streamId}`;
default: return `Event on #${event.streamId}`;
}
};

return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-slate-400 hover:text-accent transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
{events.length > 0 && (
<span className="absolute top-0 right-0 h-3 w-3 bg-accent rounded-full border-2 border-background"></span>
)}
</button>

{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-background/95 backdrop-blur-md border border-glass-border rounded-2xl shadow-2xl z-[100] overflow-hidden animate-in fade-in slide-in-from-top-2">
<div className="p-4 border-b border-glass-border flex justify-between items-center">
<h3 className="font-bold text-white">Notifications</h3>
<button
onClick={() => setIsOpen(false)}
className="text-slate-400 hover:text-white"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="p-8 flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent"></div>
</div>
) : events.length > 0 ? (
<div className="divide-y divide-glass-border">
{events.map((event) => (
<div key={event.id} className="p-4 hover:bg-white/5 transition-colors">
<p className="text-sm text-white font-medium">{formatEventMessage(event)}</p>
<p className="text-xs text-slate-400 mt-1">
{new Date(event.timestamp * 1000).toLocaleString()}
</p>
</div>
))}
</div>
) : (
<div className="p-8 text-center text-slate-400 text-sm">
No new notifications
</div>
)}
</div>
<div className="p-3 border-t border-glass-border">
<Button variant="ghost" size="sm" className="w-full text-xs">
View All Activity
</Button>
</div>
</div>
)}
</div>
);
};
Loading
Loading