Skip to content

Commit d399282

Browse files
authored
Merge pull request #748 from queenfrostbite/feature/notification-bell
feat: add notification bell with in-app notifications (#601)
2 parents c72faa9 + 8011b84 commit d399282

15 files changed

Lines changed: 965 additions & 9 deletions

File tree

backend/src/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ import {
4646
import { checkDbHealth } from './services/db';
4747
import { getCampaignTimeline, listCampaignHistory } from './services/eventHistory';
4848
import { startEventIndexer } from './services/eventIndexer';
49+
import {
50+
listNotifications,
51+
getUnreadCount,
52+
markAllRead,
53+
} from './services/notificationService';
4954
import { getDeadLetterQueue, clearDeadLetterQueue, retryDeadLetter } from './services/webhookService';
5055
import { fetchOpenIssues } from './services/openIssues';
5156
import { ensureSorobanRefundConfig, verifyRefundTransaction } from './services/sorobanRpc';
@@ -854,6 +859,50 @@ app.get('/api/leaderboard', (req: Request, res: Response) => {
854859
}
855860
});
856861

862+
// ── Notification Routes ───────────────────────────────────────────────────────
863+
864+
app.get('/api/notifications', (req: Request, res: Response) => {
865+
const wallet = normalizeQueryValue(req.query.wallet);
866+
if (!wallet) {
867+
return res.status(400).json({
868+
success: false,
869+
error: { code: 'MISSING_WALLET', message: 'wallet query parameter is required' },
870+
});
871+
}
872+
const rawLimit = normalizeQueryValue(req.query.limit);
873+
const rawOffset = normalizeQueryValue(req.query.offset);
874+
const limit = rawLimit ? Math.min(Math.max(1, Number(rawLimit)), 100) : 50;
875+
const offset = rawOffset ? Math.max(0, Number(rawOffset)) : 0;
876+
877+
const result = listNotifications(wallet, { limit, offset });
878+
const unreadCount = getUnreadCount(wallet);
879+
res.json({ data: result.data, total: result.total, unreadCount });
880+
});
881+
882+
app.get('/api/notifications/unread-count', (req: Request, res: Response) => {
883+
const wallet = normalizeQueryValue(req.query.wallet);
884+
if (!wallet) {
885+
return res.status(400).json({
886+
success: false,
887+
error: { code: 'MISSING_WALLET', message: 'wallet query parameter is required' },
888+
});
889+
}
890+
const unreadCount = getUnreadCount(wallet);
891+
res.json({ unreadCount });
892+
});
893+
894+
app.post('/api/notifications/mark-all-read', (req: Request, res: Response) => {
895+
const { wallet } = req.body as { wallet?: string };
896+
if (!wallet || typeof wallet !== 'string') {
897+
return res.status(400).json({
898+
success: false,
899+
error: { code: 'MISSING_WALLET', message: 'wallet is required in request body' },
900+
});
901+
}
902+
markAllRead(wallet);
903+
res.json({ success: true });
904+
});
905+
857906
app.get('/api/webhooks/dead-letter', (req: Request, res: Response) => {
858907
const limit = req.query.limit ? Number(req.query.limit) : 50;
859908
const entries = getDeadLetterQueue(limit);

backend/src/services/campaignStore.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getDb, initDb } from './db';
22
import { getCampaignHistory, recordEvent, BlockchainMetadata } from './eventHistory';
3+
import { createNotification } from './notificationService';
34
import { dispatchWebhook } from './webhookService';
45

56
export type CampaignStatus = 'open' | 'funded' | 'claimed' | 'failed';
@@ -986,6 +987,17 @@ export function reconcileOnChainPledge(
986987
} as BlockchainMetadata,
987988
);
988989

990+
if (campaign.creator !== input.contributor) {
991+
createNotification({
992+
campaignId,
993+
type: 'new_pledge',
994+
title: `New on-chain pledge on "${campaign.title}"`,
995+
body: `${input.contributor.slice(0, 8)}… pledged ${roundedAmount} ${assetCode} (on-chain)`,
996+
targetWallet: campaign.creator,
997+
actorWallet: input.contributor,
998+
});
999+
}
1000+
9891001
const wasFunded = campaign.pledgedAmount >= campaign.targetAmount;
9901002
const isFunded = nextPledgedAmount >= campaign.targetAmount;
9911003
if (!wasFunded && isFunded) {
@@ -995,6 +1007,13 @@ export function reconcileOnChainPledge(
9951007
assetCode,
9961008
onChain: true,
9971009
});
1010+
createNotification({
1011+
campaignId,
1012+
type: 'campaign_funded',
1013+
title: `"${campaign.title}" is funded!`,
1014+
body: `Campaign reached its goal of ${campaign.targetAmount} ${assetCode}`,
1015+
targetWallet: campaign.creator,
1016+
});
9981017
}
9991018

10001019
return true;
@@ -1277,6 +1296,13 @@ export function refundContributor(
12771296
refundedPledgeCount: refundablePledges.length,
12781297
refundedAt,
12791298
});
1299+
createNotification({
1300+
campaignId,
1301+
type: 'refund_available',
1302+
title: `Refund processed for "${campaign.title}"`,
1303+
body: `${refundedAmount} ${campaign.assetCode} has been refunded to your wallet`,
1304+
targetWallet: contributor,
1305+
});
12801306

12811307
return {
12821308
campaign: getCampaign(campaignId)!,

backend/src/services/db.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,27 @@ database.exec(`
260260
database.exec(`ALTER TABLE campaigns ADD COLUMN max_per_contributor INTEGER`);
261261
}
262262

263+
database.exec(`
264+
CREATE TABLE IF NOT EXISTS notifications (
265+
id INTEGER PRIMARY KEY AUTOINCREMENT,
266+
campaign_id TEXT NOT NULL,
267+
type TEXT NOT NULL CHECK(type IN ('new_pledge', 'campaign_funded', 'refund_available', 'creator_update')),
268+
title TEXT NOT NULL,
269+
body TEXT NOT NULL,
270+
target_wallet TEXT NOT NULL,
271+
actor_wallet TEXT,
272+
is_read INTEGER NOT NULL DEFAULT 0,
273+
created_at INTEGER NOT NULL,
274+
FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
275+
);
276+
277+
CREATE INDEX IF NOT EXISTS idx_notifications_target_wallet
278+
ON notifications(target_wallet, created_at DESC);
279+
280+
CREATE INDEX IF NOT EXISTS idx_notifications_unread
281+
ON notifications(target_wallet, is_read);
282+
`);
283+
263284
database.exec(`
264285
CREATE INDEX IF NOT EXISTS idx_campaign_events_tx_hash
265286
ON campaign_events(json_extract(blockchain_metadata, '$.txHash'));
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { getDb } from './db';
2+
3+
export type NotificationType = 'new_pledge' | 'campaign_funded' | 'refund_available' | 'creator_update';
4+
5+
export interface Notification {
6+
id: number;
7+
campaignId: string;
8+
type: NotificationType;
9+
title: string;
10+
body: string;
11+
targetWallet: string;
12+
actorWallet?: string;
13+
isRead: boolean;
14+
createdAt: number;
15+
}
16+
17+
interface NotificationRow {
18+
id: number;
19+
campaign_id: string;
20+
type: NotificationType;
21+
title: string;
22+
body: string;
23+
target_wallet: string;
24+
actor_wallet: string | null;
25+
is_read: number;
26+
created_at: number;
27+
}
28+
29+
function rowToNotification(row: NotificationRow): Notification {
30+
return {
31+
id: row.id,
32+
campaignId: row.campaign_id,
33+
type: row.type,
34+
title: row.title,
35+
body: row.body,
36+
targetWallet: row.target_wallet,
37+
actorWallet: row.actor_wallet ?? undefined,
38+
isRead: row.is_read === 1,
39+
createdAt: row.created_at,
40+
};
41+
}
42+
43+
export function createNotification(params: {
44+
campaignId: string;
45+
type: NotificationType;
46+
title: string;
47+
body: string;
48+
targetWallet: string;
49+
actorWallet?: string;
50+
}): Notification {
51+
const db = getDb();
52+
const now = Math.floor(Date.now() / 1000);
53+
const stmt = db.prepare(`
54+
INSERT INTO notifications (campaign_id, type, title, body, target_wallet, actor_wallet, created_at)
55+
VALUES (?, ?, ?, ?, ?, ?, ?)
56+
`);
57+
const result = stmt.run(
58+
params.campaignId,
59+
params.type,
60+
params.title,
61+
params.body,
62+
params.targetWallet,
63+
params.actorWallet ?? null,
64+
now,
65+
);
66+
return {
67+
id: result.lastInsertRowid as number,
68+
campaignId: params.campaignId,
69+
type: params.type,
70+
title: params.title,
71+
body: params.body,
72+
targetWallet: params.targetWallet,
73+
actorWallet: params.actorWallet,
74+
isRead: false,
75+
createdAt: now,
76+
};
77+
}
78+
79+
export function listNotifications(
80+
wallet: string,
81+
options: { limit?: number; offset?: number } = {},
82+
): { data: Notification[]; total: number } {
83+
const db = getDb();
84+
const limit = options.limit ?? 50;
85+
const offset = options.offset ?? 0;
86+
87+
const countRow = db
88+
.prepare('SELECT COUNT(*) as total FROM notifications WHERE target_wallet = ?')
89+
.get(wallet) as { total: number };
90+
91+
const rows = db
92+
.prepare(
93+
'SELECT * FROM notifications WHERE target_wallet = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
94+
)
95+
.all(wallet, limit, offset) as NotificationRow[];
96+
97+
return {
98+
data: rows.map(rowToNotification),
99+
total: countRow.total,
100+
};
101+
}
102+
103+
export function getUnreadCount(wallet: string): number {
104+
const db = getDb();
105+
const row = db
106+
.prepare(
107+
'SELECT COUNT(*) as count FROM notifications WHERE target_wallet = ? AND is_read = 0',
108+
)
109+
.get(wallet) as { count: number };
110+
return row.count;
111+
}
112+
113+
export function markAllRead(wallet: string): void {
114+
const db = getDb();
115+
db.prepare('UPDATE notifications SET is_read = 1 WHERE target_wallet = ? AND is_read = 0').run(
116+
wallet,
117+
);
118+
}
119+
120+
export function getContributorsForCampaign(campaignId: string): string[] {
121+
const db = getDb();
122+
const rows = db
123+
.prepare('SELECT DISTINCT contributor FROM pledges WHERE campaign_id = ? AND refunded_at IS NULL')
124+
.all(campaignId) as { contributor: string }[];
125+
return rows.map((r) => r.contributor);
126+
}

frontend/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
1616
<link rel="icon" type="image/svg+xml" href="/icon-192.svg" />
1717
<!-- SEO / share -->
18-
<meta name="description" content="Campaign management and funding dashboard for the Stellar ecosystem" />
18+
<meta name="description" content="Decentralized campaign funding platform on the Stellar network." />
19+
<meta property="og:title" content="Stellar Goal Vault" />
20+
<meta property="og:description" content="Decentralized campaign funding platform on the Stellar network." />
21+
<meta property="og:type" content="website" />
22+
<meta property="og:url" content="https://stellar-goal-vault.vercel.app" />
23+
<meta name="twitter:card" content="summary_large_image" />
1924
<title>Stellar Goal Vault</title>
2025
<!-- Inline theme detection to avoid FOUC — runs before React hydrates -->
2126
<script>

frontend/src/App.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { FundedConfetti } from "./components/FundedConfetti";
66
import { KeyboardShortcutsOverlay } from "./components/KeyboardShortcutsOverlay";
77
import { CampaignsTable } from "./components/CampaignsTable";
88
import { CampaignTimeline } from "./components/CampaignTimeline";
9+
import { NotificationBell } from "./components/NotificationBell";
910
import { CreateCampaignForm } from "./components/CreateCampaignForm";
1011
import { CreatorAnalytics } from "./components/CreatorAnalytics";
1112
import { IssueBacklog } from "./components/IssueBacklog";
@@ -38,6 +39,8 @@ import { submitRefundTransaction } from "./services/soroban";
3839
import { useWallet } from "./hooks/useWallet";
3940
import { useLocalStorage } from "./hooks/useLocalStorage";
4041
import { useToast } from "./hooks/useToast";
42+
import { useOpenGraph } from "./hooks/useOpenGraph";
43+
import { useCampaignShareCard } from "./components/CampaignShareCard";
4144
import { didCampaignBecomeFunded } from "./lib/fundingCelebration";
4245
import {
4346
ApiError,
@@ -331,6 +334,8 @@ function App() {
331334
await Promise.all([refreshHistory(campaignId), refreshSelectedCampaign(campaignId)]);
332335
}
333336

337+
const { toDataUrl } = useCampaignShareCard();
338+
334339
const initialParamIdRef = useRef(paramId);
335340

336341
useEffect(() => {
@@ -433,6 +438,20 @@ function App() {
433438
};
434439
}, [campaigns, selectedCampaignDetails, selectedCampaignId]);
435440

441+
const ogMeta = useMemo(() => {
442+
const c = selectedCampaign;
443+
if (!c) return null;
444+
const baseUrl = window.location.origin;
445+
return {
446+
title: `${c.title} — Stellar Goal Vault`,
447+
description: c.description.slice(0, 200),
448+
image: c.metadata?.imageUrl ?? undefined,
449+
url: `${baseUrl}/campaigns/${c.id}`,
450+
};
451+
}, [selectedCampaign]);
452+
453+
useOpenGraph(ogMeta);
454+
436455
const metrics = useMemo(() => {
437456
const open = campaigns.filter((campaign) => campaign.progress.status === "open").length;
438457
const funded = campaigns.filter((campaign) => campaign.progress.status === "funded").length;
@@ -676,6 +695,7 @@ function App() {
676695
onDisconnect={handleDisconnectWallet}
677696
onSwitchWallet={wallet.openPicker}
678697
/>
698+
<NotificationBell wallet={connectedWallet} campaignId={selectedCampaignId} />
679699
<button className="btn-ghost" type="button" onClick={handleThemeToggle}>
680700
{themeMode === "dark" ? "Light mode" : "Dark mode"}
681701
</button>

frontend/src/components/CampaignCard.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link } from 'lucide-react';
33
import { Campaign } from '../types/campaign';
44
import AddressAvatar from './AddressAvatar';
55
import CopyButton from './CopyButton';
6+
import { Countdown } from './Countdown';
67

78
interface CampaignCardProps {
89
campaign: Campaign;
@@ -30,8 +31,6 @@ function CampaignCardInner({ campaign, selectedCampaignId, onSelect }: CampaignC
3031
setImageError(false);
3132
}, [campaign.id]);
3233

33-
const formatTimestamp = (unixSeconds: number) => new Date(unixSeconds * 1000).toLocaleString();
34-
3534
const handleShareCampaign = () => {
3635
const deepLinkUrl = `${window.location.origin}${window.location.pathname}?campaign=${campaign.id}`;
3736
navigator.clipboard.writeText(deepLinkUrl).then(() => {
@@ -163,7 +162,7 @@ function CampaignCardInner({ campaign, selectedCampaignId, onSelect }: CampaignC
163162
<span className={`badge badge-${campaign.progress.status}`}>
164163
{campaign.progress.status}
165164
</span>
166-
<div className="muted">{formatTimestamp(campaign.deadline)}</div>
165+
<div className="muted"><Countdown deadline={campaign.deadline} /></div>
167166
</div>
168167
</div>
169168

0 commit comments

Comments
 (0)