diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 00000000..c77a2520
--- /dev/null
+++ b/frontend/public/sw.js
@@ -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());
+});
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 4334cb6e..e46dec8d 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -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'] });
@@ -16,7 +17,9 @@ export default function RootLayout({
}) {
return (
-
{children}
+
+ {children}
+
);
}
diff --git a/frontend/src/app/notifications/page.tsx b/frontend/src/app/notifications/page.tsx
new file mode 100644
index 00000000..5dcef8e7
--- /dev/null
+++ b/frontend/src/app/notifications/page.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+ {allNotifications.length === 0 ? (
+
+
+
No notifications yet
+
+ Notifications about credential expiry, verification, and sharing will appear here
+
+
+ ) : (
+ allNotifications.map((n) => (
+
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" : ""
+ }`}
+ >
+
+
+
+ {!n.read && (
+
+ )}
+
+ {n.title}
+
+
+ {n.type.replace("_", " ")}
+
+
+
{n.message}
+
{formatDate(n.timestamp)}
+
+
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index a0b6c305..51e66e45 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -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');
@@ -20,7 +22,10 @@ export default function Home() {
Tamper-Proof Health Credentials on Stellar Soroban
Prove vaccination status with zero-knowledge proofs — no names, no birthdates, no medical history exposed
-
+
+
+
+
@@ -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) => (
}
{activeTab === 'verification' && }
{activeTab === 'sharing' && }
+ {activeTab === 'notifications' && }
>
) : (
diff --git a/frontend/src/components/NotificationBell.tsx b/frontend/src/components/NotificationBell.tsx
new file mode 100644
index 00000000..041a7258
--- /dev/null
+++ b/frontend/src/components/NotificationBell.tsx
@@ -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 = {
+ 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 (
+ onRead(notification.id)}
+ >
+
+
+
{notification.title}
+
{notification.message}
+
{formatTime(notification.timestamp)}
+
+
+
+
+ );
+}
+
+export function NotificationBell() {
+ const { notifications, unreadCount, markAsRead, markAllRead, clearNotification } =
+ useNotifications();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(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 (
+
+
+
+ {open && (
+
+
+
+
+ {recent.length === 0 ? (
+
+
+ No notifications yet
+
+ ) : (
+ recent.map((n) => (
+
+ ))
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/NotificationPreferences.tsx b/frontend/src/components/NotificationPreferences.tsx
new file mode 100644
index 00000000..f0c66e45
--- /dev/null
+++ b/frontend/src/components/NotificationPreferences.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import React from "react";
+import { Bell, Mail, Smartphone, Shield } from "lucide-react";
+import { useNotifications } from "@/contexts/NotificationContext";
+
+export function NotificationPreferences() {
+ const { preferences, updatePreferences, requestPushPermission } = useNotifications();
+
+ return (
+
+
+
+
+ Notification Channels
+
+
+
+
+
+
+
+
+ {preferences.email && (
+
+ updatePreferences({ emailAddress: e.target.value })}
+ className="w-full p-2 bg-white/10 border border-white/20 rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-green-500"
+ />
+
+ )}
+
+
+
+
+
+
+ Alert Types
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx
new file mode 100644
index 00000000..27bb0423
--- /dev/null
+++ b/frontend/src/contexts/NotificationContext.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
+
+export type NotificationType = "credential_expiry" | "verification_complete" | "sharing_request" | "system_update";
+
+export interface AppNotification {
+ id: string;
+ type: NotificationType;
+ title: string;
+ message: string;
+ timestamp: string;
+ read: boolean;
+ actionUrl?: string;
+}
+
+export interface NotificationPreferences {
+ inApp: boolean;
+ push: boolean;
+ email: boolean;
+ emailAddress: string;
+ notifyExpiry: boolean;
+ notifyVerification: boolean;
+ notifySharing: boolean;
+}
+
+interface NotificationContextValue {
+ notifications: AppNotification[];
+ unreadCount: number;
+ preferences: NotificationPreferences;
+ markAsRead: (id: string) => void;
+ markAllRead: () => void;
+ addNotification: (notification: Omit) => void;
+ clearNotification: (id: string) => void;
+ updatePreferences: (prefs: Partial) => void;
+ requestPushPermission: () => Promise;
+ allNotifications: AppNotification[];
+}
+
+const NotificationContext = createContext(null);
+
+const STORAGE_KEY = "validfi_notifications";
+const PREFS_KEY = "validfi_notification_prefs";
+
+const DEFAULT_PREFERENCES: NotificationPreferences = {
+ inApp: true,
+ push: false,
+ email: false,
+ emailAddress: "",
+ notifyExpiry: true,
+ notifyVerification: true,
+ notifySharing: true,
+};
+
+function generateId(): string {
+ return `notif_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
+}
+
+export function NotificationProvider({ children }: { children: ReactNode }) {
+ const [notifications, setNotifications] = useState([]);
+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES);
+
+ useEffect(() => {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) setNotifications(JSON.parse(stored));
+ const storedPrefs = localStorage.getItem(PREFS_KEY);
+ if (storedPrefs) setPreferences({ ...DEFAULT_PREFERENCES, ...JSON.parse(storedPrefs) });
+ } catch {
+ // ignore corrupted storage
+ }
+ }, []);
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
+ }, [notifications]);
+
+ useEffect(() => {
+ localStorage.setItem(PREFS_KEY, JSON.stringify(preferences));
+ }, [preferences]);
+
+ const unreadCount = notifications.filter((n) => !n.read).length;
+
+ const markAsRead = useCallback((id: string) => {
+ setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
+ }, []);
+
+ const markAllRead = useCallback(() => {
+ setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
+ }, []);
+
+ const addNotification = useCallback(
+ (notif: Omit) => {
+ const newNotif: AppNotification = {
+ ...notif,
+ id: generateId(),
+ timestamp: new Date().toISOString(),
+ read: false,
+ };
+ setNotifications((prev) => [newNotif, ...prev].slice(0, 100));
+ return newNotif;
+ },
+ []
+ );
+
+ const clearNotification = useCallback((id: string) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, []);
+
+ const updatePreferences = useCallback((prefs: Partial) => {
+ setPreferences((prev) => ({ ...prev, ...prefs }));
+ }, []);
+
+ const requestPushPermission = useCallback(async (): Promise => {
+ if (typeof window === "undefined" || !("Notification" in window)) return false;
+ const result = await Notification.requestPermission();
+ if (result === "granted") {
+ updatePreferences({ push: true });
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
+ }
+ return true;
+ }
+ return false;
+ }, [updatePreferences]);
+
+ const allNotifications = notifications;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNotifications(): NotificationContextValue {
+ const ctx = useContext(NotificationContext);
+ if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
+ return ctx;
+}