Skip to content

Commit 983392c

Browse files
authored
Merge pull request #156 from khalifa-zoro/Add-creator-analytics-cards
Closes #13, Added Creator Analytic cards and fixed a few issues
2 parents 99e2d73 + 3f4bd8e commit 983392c

6 files changed

Lines changed: 180 additions & 30 deletions

File tree

backend/src/index.ts

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { getCampaignHistory } from "./services/eventHistory";
2828
import { startEventIndexer } from "./services/eventIndexer";
2929
import { fetchOpenIssues } from "./services/openIssues";
3030
import { ensureSorobanRefundConfig, verifyRefundTransaction } from "./services/sorobanRpc";
31-
import { AppError, ApiErrorResponse } from "./types/errors";
31+
import { AppError, ApiErrorResponse, RequestWithId, CampaignListItem } from "./types/errors";
3232
import {
3333
campaignIdSchema,
3434
claimCampaignPayloadSchema,
@@ -78,43 +78,15 @@ app.use(
7878

7979
app.use(express.json());
8080

81-
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();
8281

83-
function applyRateLimit(maxRequests: number) {
84-
return (req: Request, res: Response, next: express.NextFunction) => {
85-
const key = `${req.ip}:${req.path}:${maxRequests}`;
86-
const now = Date.now();
87-
const current = rateLimitBuckets.get(key);
88-
89-
if (!current || now >= current.resetAt) {
90-
rateLimitBuckets.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
91-
return next();
92-
}
93-
94-
if (current.count >= maxRequests) {
95-
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
96-
res.setHeader("Retry-After", String(retryAfterSec));
97-
throw new AppError("Rate limit exceeded. Please retry shortly.", 429, "RATE_LIMITED");
98-
}
99-
100-
current.count += 1;
101-
rateLimitBuckets.set(key, current);
102-
return next();
103-
};
104-
}
105-
106-
app.use(applyRateLimit(RATE_LIMIT_MAX_REQUESTS));
107-
108-
app.use((req: RequestWithId, res: Response, next: express.NextFunction) => {
109-
req.requestId = randomUUID();
11082
const startedAt = process.hrtime.bigint();
11183

11284
res.on("finish", () => {
11385
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
11486

11587
logRequest(
11688
{
117-
requestId: req.requestId,
89+
requestId: requestWithId.requestId,
11890
method: req.method,
11991
path: req.originalUrl || req.path,
12092
status: res.statusCode,

backend/src/types/errors.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Request } from "express";
2+
13
export interface ApiErrorDetail {
24
field: string;
35
message: string;
@@ -13,6 +15,37 @@ export interface ApiErrorResponse {
1315
};
1416
}
1517

18+
export interface RequestWithId extends Request {
19+
requestId: string;
20+
}
21+
22+
export interface CampaignListItem {
23+
id: string;
24+
creator: string;
25+
title: string;
26+
description: string;
27+
assetCode: string;
28+
targetAmount: number;
29+
pledgedAmount: number;
30+
deadline: number;
31+
createdAt: number;
32+
claimedAt?: number;
33+
progress: {
34+
status: "open" | "funded" | "claimed" | "failed";
35+
percentFunded: number;
36+
remainingAmount: number;
37+
pledgeCount: number;
38+
hoursLeft: number;
39+
canPledge: boolean;
40+
canClaim: boolean;
41+
canRefund: boolean;
42+
};
43+
metadata?: {
44+
imageUrl?: string;
45+
externalLink?: string;
46+
};
47+
}
48+
1649
export class AppError extends Error {
1750
constructor(
1851
public message: string,

frontend/src/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { KeyboardShortcutsOverlay } from "./components/KeyboardShortcutsOverlay"
44
import { CampaignsTable } from "./components/CampaignsTable";
55
import { CampaignTimeline } from "./components/CampaignTimeline";
66
import { CreateCampaignForm } from "./components/CreateCampaignForm";
7+
import { CreatorAnalytics } from "./components/CreatorAnalytics";
78
import { IssueBacklog } from "./components/IssueBacklog";
89
import { TransactionPreviewModal, TransactionPreviewData } from "./components/TransactionPreviewModal";
910
import { ToastContainer } from "./components/ToastContainer";
@@ -462,6 +463,19 @@ function handleSelect(campaignId: string) {
462463
</article>
463464
</section>
464465

466+
{selectedCampaign && (
467+
<section
468+
className="animate-fade-in"
469+
style={{ animationDelay: "0.1s" }}
470+
>
471+
<CreatorAnalytics
472+
creatorAddress={selectedCampaign.creator}
473+
campaigns={campaigns}
474+
isLoading={isCampaignsLoading || initialLoad}
475+
/>
476+
</section>
477+
)}
478+
465479
<section
466480
className="layout-grid animate-fade-in"
467481
style={{ animationDelay: "0.2s" }}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React, { useMemo } from 'react';
2+
import { Campaign } from '../types/campaign';
3+
4+
interface CreatorAnalyticsProps {
5+
creatorAddress: string;
6+
campaigns: Campaign[];
7+
isLoading?: boolean;
8+
}
9+
10+
export const CreatorAnalytics: React.FC<CreatorAnalyticsProps> = ({
11+
creatorAddress,
12+
campaigns,
13+
isLoading = false,
14+
}) => {
15+
const metrics = useMemo(() => {
16+
if (!creatorAddress || !campaigns.length) {
17+
return {
18+
campaignsCreated: 0,
19+
fundedCampaigns: 0,
20+
claimedVaults: 0,
21+
};
22+
}
23+
24+
const creatorCampaigns = campaigns.filter(
25+
(c) => c.creator.toLowerCase() === creatorAddress.toLowerCase()
26+
);
27+
28+
const fundedCampaigns = creatorCampaigns.filter(
29+
(c) => c.progress.status === 'funded'
30+
).length;
31+
32+
const claimedVaults = creatorCampaigns.filter(
33+
(c) => c.progress.status === 'claimed'
34+
).length;
35+
36+
return {
37+
campaignsCreated: creatorCampaigns.length,
38+
fundedCampaigns,
39+
claimedVaults,
40+
};
41+
}, [creatorAddress, campaigns]);
42+
43+
if (isLoading) {
44+
return (
45+
<div className="creator-metrics-container">
46+
<h3 className="creator-metrics-title">Creator Performance</h3>
47+
<div className="metric-grid">
48+
<div className="metric-card">
49+
<span>Campaigns Created</span>
50+
<strong></strong>
51+
</div>
52+
<div className="metric-card">
53+
<span>Funded Campaigns</span>
54+
<strong></strong>
55+
</div>
56+
<div className="metric-card">
57+
<span>Claimed Vaults</span>
58+
<strong></strong>
59+
</div>
60+
</div>
61+
</div>
62+
);
63+
}
64+
65+
return (
66+
<div className="creator-metrics-container">
67+
<h3 className="creator-metrics-title">
68+
Creator Performance: <code className="creator-address">{creatorAddress}</code>
69+
</h3>
70+
<div className="metric-grid">
71+
<div className="metric-card animate-fade-in">
72+
<span>Campaigns Created</span>
73+
<strong>{metrics.campaignsCreated}</strong>
74+
</div>
75+
<div className="metric-card animate-fade-in" style={{ animationDelay: '0.1s' }}>
76+
<span>Funded Campaigns</span>
77+
<strong>{metrics.fundedCampaigns}</strong>
78+
</div>
79+
<div className="metric-card animate-fade-in" style={{ animationDelay: '0.2s' }}>
80+
<span>Claimed Vaults</span>
81+
<strong>{metrics.claimedVaults}</strong>
82+
</div>
83+
</div>
84+
</div>
85+
);
86+
};

frontend/src/index.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,3 +1503,47 @@ tbody tr:hover td {
15031503
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.08);
15041504
}
15051505
}
1506+
1507+
/* Creator Analytics Styles */
1508+
.creator-metrics-container {
1509+
margin-bottom: 60px;
1510+
}
1511+
1512+
.creator-metrics-title {
1513+
margin: 0 0 24px;
1514+
font-size: 1.25rem;
1515+
font-weight: 600;
1516+
color: var(--text-main);
1517+
display: flex;
1518+
align-items: center;
1519+
gap: 8px;
1520+
}
1521+
1522+
.creator-address {
1523+
font-family: 'Monaco', 'Courier New', monospace;
1524+
font-size: 0.9rem;
1525+
background: rgba(99, 102, 241, 0.1);
1526+
padding: 4px 8px;
1527+
border-radius: 6px;
1528+
color: var(--primary);
1529+
border: 1px solid var(--border-glass);
1530+
max-width: 320px;
1531+
overflow: hidden;
1532+
text-overflow: ellipsis;
1533+
}
1534+
1535+
@media (max-width: 767px) {
1536+
.creator-metrics-container {
1537+
margin-bottom: 40px;
1538+
}
1539+
1540+
.creator-metrics-title {
1541+
font-size: 1.1rem;
1542+
flex-wrap: wrap;
1543+
}
1544+
1545+
.creator-address {
1546+
font-size: 0.8rem;
1547+
max-width: 100%;
1548+
}
1549+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "Crowdfunding and goal-vault MVP built on Stellar",
55
"private": true,
66
"scripts": {
7+
"dev": "cd frontend && npm run dev",
78
"dev:backend": "cd backend && npm run dev",
89
"dev:frontend": "cd frontend && npm run dev",
910
"install:all": "cd backend && npm install && cd ../frontend && npm install",

0 commit comments

Comments
 (0)