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
60 changes: 60 additions & 0 deletions frontend/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
self.addEventListener("push", (event: any) => {
if (!event.data) return;

try {
const data = event.data.json();
const title = data.title || "ValidFi Notification";
const options: NotificationOptions = {
body: data.message || "",
icon: "/icon-192.png",
badge: "/badge.png",
tag: data.tag || "validfi-notification",
data: {
url: data.url || "/notifications",
timestamp: Date.now(),
type: data.type || "system_update",
},
requireInteraction: data.requireInteraction || false,
vibrate: [200, 100, 200],
};

event.waitUntil(self.registration.showNotification(title, options));
} catch {
const title = "ValidFi Notification";
const options: NotificationOptions = {
body: event.data.text(),
icon: "/icon-192.png",
badge: "/badge.png",
};
event.waitUntil(self.registration.showNotification(title, options));
}
});

self.addEventListener("notificationclick", (event: any) => {
event.notification.close();

const targetUrl = event.notification.data?.url || "/notifications";

event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if (client.url.includes(targetUrl) && "focus" in client) {
return (client as WindowClient).focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
);
});

self.addEventListener("install", () => {
(self as any).skipWaiting();
});

self.addEventListener("activate", (event: any) => {
event.waitUntil(self.clients.claim());
});
5 changes: 4 additions & 1 deletion frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { NotificationProvider } from '@/contexts/NotificationContext';

const inter = Inter({ subsets: ['latin'] });

Expand All @@ -16,7 +17,9 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<NotificationProvider>{children}</NotificationProvider>
</body>
</html>
);
}
118 changes: 118 additions & 0 deletions frontend/src/app/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use client";

import React from "react";
import Link from "next/link";
import { ArrowLeft, Bell, Trash2 } from "lucide-react";
import { useNotifications, AppNotification } from "@/contexts/NotificationContext";

const TYPE_COLORS: Record<string, string> = {
credential_expiry: "text-amber-400",
verification_complete: "text-green-400",
sharing_request: "text-blue-400",
system_update: "text-purple-400",
};

function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

export default function NotificationHistoryPage() {
const { allNotifications, markAsRead, markAllRead, clearNotification, unreadCount } =
useNotifications();

return (
<main className="min-h-screen bg-gradient-to-br from-green-900 via-teal-900 to-blue-900">
<div className="container mx-auto px-4 py-8 max-w-2xl">
<header className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/"
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Bell className="w-6 h-6 text-green-400" />
Notification History
</h1>
<p className="text-sm text-white/50 mt-1">
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount > 1 ? "s" : ""}`
: "All caught up"}
</p>
</div>
</div>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="px-3 py-1.5 text-sm text-green-400 hover:bg-green-500/10 rounded-lg transition-colors"
>
Mark all read
</button>
)}
</div>
</header>

<div className="space-y-2">
{allNotifications.length === 0 ? (
<div className="bg-white/5 backdrop-blur-lg rounded-xl p-12 text-center">
<Bell className="w-12 h-12 mx-auto mb-4 text-white/20" />
<p className="text-white/40 text-lg">No notifications yet</p>
<p className="text-white/30 text-sm mt-1">
Notifications about credential expiry, verification, and sharing will appear here
</p>
</div>
) : (
allNotifications.map((n) => (
<div
key={n.id}
onClick={() => markAsRead(n.id)}
className={`p-4 bg-white/5 backdrop-blur-lg rounded-xl transition-colors cursor-pointer hover:bg-white/10 ${
!n.read ? "border-l-2 border-green-500" : ""
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{!n.read && (
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
)}
<h3
className={`text-sm font-semibold text-white ${!n.read ? "" : "opacity-70"}`}
>
{n.title}
</h3>
<span className={`text-[10px] uppercase font-bold ${TYPE_COLORS[n.type] || "text-white/40"}`}>
{n.type.replace("_", " ")}
</span>
</div>
<p className="text-sm text-white/60 leading-relaxed">{n.message}</p>
<p className="text-[11px] text-white/30 mt-2">{formatDate(n.timestamp)}</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
clearNotification(n.id);
}}
className="p-1.5 text-white/20 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors shrink-0"
aria-label="Delete notification"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
</div>
</main>
);
}
9 changes: 8 additions & 1 deletion frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { WalletConnect } from '@/components/wallet-connect';
import { HealthCredentialVault } from '@/components/health-credential-vault';
import { VaccinationVerificationCenter } from '@/components/vaccination-verification-center';
import { CredentialSharing } from '@/components/credential-sharing';
import { NotificationBell } from '@/components/NotificationBell';
import { NotificationPreferences } from '@/components/NotificationPreferences';

export default function Home() {
const [activeTab, setActiveTab] = useState('vault');
Expand All @@ -20,7 +22,10 @@ export default function Home() {
<p className="text-green-200">Tamper-Proof Health Credentials on Stellar Soroban</p>
<p className="text-green-300 text-sm mt-1">Prove vaccination status with zero-knowledge proofs — no names, no birthdates, no medical history exposed</p>
</div>
<WalletConnect onConnect={setWalletAddress} />
<div className="flex items-center gap-4">
<NotificationBell />
<WalletConnect onConnect={setWalletAddress} />
</div>
</div>
</header>

Expand All @@ -32,6 +37,7 @@ export default function Home() {
{ id: 'vault', label: 'Health Credential Vault' },
{ id: 'verification', label: 'Vaccination Verification' },
{ id: 'sharing', label: 'Credential Sharing' },
{ id: 'notifications', label: 'Notification Settings' },
].map((tab) => (
<button
key={tab.id}
Expand All @@ -52,6 +58,7 @@ export default function Home() {
{activeTab === 'vault' && <HealthCredentialVault walletAddress={walletAddress} />}
{activeTab === 'verification' && <VaccinationVerificationCenter walletAddress={walletAddress} />}
{activeTab === 'sharing' && <CredentialSharing walletAddress={walletAddress} />}
{activeTab === 'notifications' && <NotificationPreferences />}
</div>
</>
) : (
Expand Down
138 changes: 138 additions & 0 deletions frontend/src/components/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";

import React, { useState, useRef, useEffect } from "react";
import { Bell, X, ExternalLink } from "lucide-react";
import { useNotifications, AppNotification } from "@/contexts/NotificationContext";

const TYPE_STYLES: Record<string, string> = {
credential_expiry: "border-l-amber-500 bg-amber-500/10",
verification_complete: "border-l-green-500 bg-green-500/10",
sharing_request: "border-l-blue-500 bg-blue-500/10",
system_update: "border-l-purple-500 bg-purple-500/10",
};

function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return "Just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}

function NotificationItem({
notification,
onRead,
onClear,
}: {
notification: AppNotification;
onRead: (id: string) => void;
onClear: (id: string) => void;
}) {
return (
<div
className={`p-3 border-l-2 rounded-r transition-colors cursor-pointer hover:bg-white/5 ${
notification.read ? "opacity-60" : ""
} ${TYPE_STYLES[notification.type] || "border-l-gray-500 bg-gray-500/10"}`}
onClick={() => onRead(notification.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">{notification.title}</p>
<p className="text-xs text-white/70 mt-0.5 line-clamp-2">{notification.message}</p>
<p className="text-[10px] text-white/40 mt-1">{formatTime(notification.timestamp)}</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClear(notification.id);
}}
className="shrink-0 p-0.5 text-white/30 hover:text-white/70 transition-colors"
aria-label="Dismiss notification"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

export function NotificationBell() {
const { notifications, unreadCount, markAsRead, markAllRead, clearNotification } =
useNotifications();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);

const recent = notifications.slice(0, 10);

return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="relative p-2 text-white/70 hover:text-white transition-colors rounded-lg hover:bg-white/10"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[18px] h-[18px] text-[10px] font-bold bg-red-500 text-white rounded-full px-1">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>

{open && (
<div className="absolute right-0 top-full mt-2 w-80 max-h-96 bg-gray-900 border border-white/20 rounded-xl shadow-2xl overflow-hidden z-50">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-semibold text-white">Notifications</h3>
<div className="flex items-center gap-3">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-[11px] text-green-400 hover:text-green-300 transition-colors"
>
Mark all read
</button>
)}
<a
href="/notifications"
onClick={() => setOpen(false)}
className="flex items-center gap-1 text-[11px] text-white/50 hover:text-white/80 transition-colors"
>
View all <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>

<div className="overflow-y-auto max-h-72">
{recent.length === 0 ? (
<div className="py-8 text-center text-white/40 text-sm">
<Bell className="w-8 h-8 mx-auto mb-2 opacity-30" />
No notifications yet
</div>
) : (
recent.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onRead={markAsRead}
onClear={clearNotification}
/>
))
)}
</div>
</div>
)}
</div>
);
}
Loading