Skip to content
Open
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
219 changes: 62 additions & 157 deletions src/components/dashboard/recent-activity.tsx
Original file line number Diff line number Diff line change
@@ -1,178 +1,83 @@
"use client";
import React from'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { formatDistanceToNow } from 'date-fns';
import { useWallet } from '@creit.tech/stellar-wallets-kit';
import { Notification } from '@/types';
import { IconContribution, IconLoan, IconVote, IconDistribution } from 'lucide-react';

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
import { CheckCheck, Wallet, DollarSign, CreditCard, ThumbsUp, Send } from "lucide-react";
import { clsx } from "clsx";
import { useWallet } from "@/hooks/use-wallet";
import type { Notification } from "@/types";

// TODO: Replace with real API responses once backend is ready
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: "mock-1",
recipient: "",
type: "contribution",
description: "Adaeze contributed 50 USDC to Eko Savings",
read: false,
createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
},
{
id: "mock-2",
recipient: "",
type: "loan",
description: "Chidi applied for a 200 USDC loan",
read: true,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
},
{
id: "mock-3",
recipient: "",
type: "vote",
description: "Voting ended for Proposal #12",
read: false,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
},
{
id: "mock-4",
recipient: "",
type: "distribution",
description: "50 USDC distributed to your account",
read: true,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
},
];

async function fetchNotifications(recipient: string): Promise<Notification[]> {
// TODO: connect to API
if (!recipient) return MOCK_NOTIFICATIONS;
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/notifications?recipient=${encodeURIComponent(recipient)}`
);
if (!res.ok) throw new Error("Failed to fetch notifications");
const data: unknown = await res.json();
if (!Array.isArray(data) || data.length === 0) return [];
return data as Notification[];
} catch {
// TODO: connect to API — returning mock data on error
return MOCK_NOTIFICATIONS;
}
}
const getNotifications = async (wallet: string) => {
const { data } = await axios.get<Notification[]>(`/api/notifications?recipient=${wallet}`);
return data;
};

async function markNotificationAsRead(id: string): Promise<void> {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/notifications/${encodeURIComponent(id)}/read`,
{ method: "PATCH" }
const markAllRead = async (notifications: Notification[]) => {
const promises = notifications.map((notification) =>
axios.patch(`/api/notifications/${notification.id}/read`)
);
if (!res.ok) throw new Error("Failed to mark notification as read");
}

const ACTIVITY_CONFIG: Record<string, { icon: typeof DollarSign; color: string; bg: string }> = {
contribution: { icon: DollarSign, color: "text-green-600", bg: "bg-green-50" },
loan: { icon: CreditCard, color: "text-amber-600", bg: "bg-amber-50" },
vote: { icon: ThumbsUp, color: "text-blue-600", bg: "bg-blue-50" },
distribution: { icon: Send, color: "text-purple-600", bg: "bg-purple-50" },
await Promise.all(promises);
};

export function RecentActivity() {
const { address, connect } = useWallet();
const queryClient = useQueryClient();
const RecentActivity: React.FC = () => {
const { connectedWallet } = useWallet();

const { data = [], isLoading } = useQuery({
queryKey: ["notifications", address],
queryFn: () => fetchNotifications(address ?? ""),
enabled: !!address,
staleTime: 30_000,
});
const { data: notifications, isLoading } = useQuery(
['notifications', connectedWallet],
() => getNotifications(connectedWallet || ''),
{
enabled:!!connectedWallet,
}
);

const mutation = useMutation({
mutationFn: markNotificationAsRead,
const markAllReadMutation = useMutation(markAllRead, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
// Refetch notifications after marking all as read
useQuery.refetchQueries('notifications');
},
});

const hasUnread = data.some((n) => !n.read);

if (!address) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 h-64 flex flex-col items-center justify-center text-center gap-3">
<Wallet className="w-8 h-8 text-gray-300" />
<p className="text-sm text-gray-500">Connect your wallet to see activity</p>
<button
type="button"
onClick={connect}
className="text-xs font-medium text-brand-600 hover:text-brand-700 underline"
>
Connect Wallet
</button>
</div>
);
if (!connectedWallet) {
return <div>Connect your wallet to see activity</div>;
}

if (isLoading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-3">
<div className="h-4 w-24 bg-gray-100 animate-pulse rounded" />
{[...Array(4)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-8 h-8 bg-gray-100 animate-pulse rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-gray-100 animate-pulse rounded" />
<div className="h-2 w-16 bg-gray-100 animate-pulse rounded" />
</div>
</div>
))}
</div>
);
return <div>Loading...</div>;
}

if (!notifications?.length) {
return <div>No recent activity</div>;
}

const unreadNotifications = notifications.filter((n) =>!n.read);

return (
<div className="bg-white rounded-xl border border-gray-200 p-5 h-64 flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900">Recent Activity</h3>
{hasUnread && (
<button
type="button"
disabled={mutation.isPending}
onClick={() => {
data.filter((n) => !n.read).forEach((n) => mutation.mutate(n.id));
}}
className="text-xs text-brand-600 hover:text-brand-700 font-medium flex items-center gap-1 disabled:opacity-50"
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</button>
)}
</div>
<div className="flex-1 overflow-y-auto -mx-5 px-5">
{data.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<p className="text-sm text-gray-400">No recent activity</p>
<div className="space-y-4">
{unreadNotifications.length > 0 && (
<button
className="text-blue-500"
onClick={() => markAllReadMutation.mutate(notifications)}
>
Mark all read
</button>
)}
{notifications.map((notification) => (
<div key={notification.id} className="flex items-center space-x-2 border-b border-gray-200 py-2">
<div className="text-lg">
{notification.type === 'contribution' && <IconContribution />}
{notification.type === 'loan' && <IconLoan />}
{notification.type === 'vote' && <IconVote />}
{notification.type === 'distribution' && <IconDistribution />}
</div>
) : (
<div className="divide-y divide-gray-100">
{data.map((notification) => {
const config = ACTIVITY_CONFIG[notification.type] || ACTIVITY_CONFIG.contribution;
const Icon = config.icon;
return (
<div key={notification.id} className="flex items-start gap-3 py-3">
<div className={clsx("w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0", config.bg)}>
<Icon className={clsx("w-4 h-4", config.color)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-700 leading-snug">{notification.description}</p>
<p className="text-xs text-gray-400 mt-0.5">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
</div>
);
})}
<div className="flex-1">
<div>{notification.description}</div>
<div className="text-sm text-gray-500">
{formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })}
</div>
</div>
)}
</div>
</div>
))}
</div>
);
}
};

export default RecentActivity;
102 changes: 2 additions & 100 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,105 +1,7 @@
export interface Group {
id: string;
name: string;
description: string;
admin: string;
members: Member[];
totalContributions: number;
balance: number;
isActive: boolean;
createdAt: string;
rules: CoopRules;
contractAddresses: ContractAddresses;
}

export interface Member {
address: string;
displayName?: string;
totalContributed: number;
joinedAt: string;
isActive: boolean;
loanBalance: number;
}

export interface Contribution {
id: string;
member: string;
amount: number;
period: number;
timestamp: string;
txHash: string;
}

export interface Loan {
id: number;
borrower: string;
amount: number;
interestBps: number;
repaymentDue: string;
amountRepaid: number;
status: "Pending" | "Approved" | "Repaid" | "Rejected" | "Defaulted";
purpose: string;
requestedAt: string;
approvedAt?: string;
}

export interface Proposal {
id: number;
proposer: string;
type: "LoanApproval" | "TreasurySpend" | "AddMember" | "RemoveMember" | "UpdateRule" | "General";
title: string;
description: string;
votesFor: number;
votesAgainst: number;
quorum: number;
deadline: string;
status: "Active" | "Passed" | "Failed" | "Executed";
createdAt: string;
payload?: string;
}

export interface Distribution {
id: number;
totalProfit: number;
totalShares: number;
recipients: string[];
amounts: number[];
executedAt: string;
period: string;
}

export interface CoopRules {
minContribution: number;
contributionPeriodDays: number;
maxLoanMultiplier: number;
loanInterestBps: number;
votingQuorum: number;
votingPeriodDays: number;
latePenaltyBps: number;
}

export interface ContractAddresses {
treasury: string;
loan: string;
voting: string;
governance: string;
dividend: string;
}

export interface Notification {
id: string;
recipient: string;
type: "contribution" | "loan" | "vote" | "distribution";
type: 'contribution' | 'loan' | 'vote' | 'distribution';
description: string;
timestamp: string;
read: boolean;
createdAt: string;
}

export interface DashboardStats {
totalGroups: number;
totalMembers: number;
totalContributions: number;
totalLoansActive: number;
totalLoansValue: number;
totalDividendsDistributed: number;
}