From 1cd30880879b566d11037dbbdce091b04ab7ee23 Mon Sep 17 00:00:00 2001 From: jamalx-max Date: Sat, 27 Jun 2026 09:12:24 +0100 Subject: [PATCH] feat: implement comprehensive credential notification system - NotificationContext with localStorage persistence for notification state - NotificationBell component with dropdown and unread badge - NotificationPreferences panel for channel and type settings - /notifications history page with read/unread states - Service worker (sw.js) for browser push notifications - Supports: in-app notifications, push, email preferences - Notification types: credential expiry, verification, sharing, system - Integrated into layout and main page navigation Closes #76 --- frontend/public/sw.js | 60 +++++++ frontend/src/app/layout.tsx | 5 +- frontend/src/app/notifications/page.tsx | 118 ++++++++++++++ frontend/src/app/page.tsx | 9 +- frontend/src/components/NotificationBell.tsx | 138 ++++++++++++++++ .../components/NotificationPreferences.tsx | 143 ++++++++++++++++ frontend/src/contexts/NotificationContext.tsx | 153 ++++++++++++++++++ 7 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/app/notifications/page.tsx create mode 100644 frontend/src/components/NotificationBell.tsx create mode 100644 frontend/src/components/NotificationPreferences.tsx create mode 100644 frontend/src/contexts/NotificationContext.tsx 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 ( +
+
+
+
+
+ + + +
+

+ + Notification History +

+

+ {unreadCount > 0 + ? `${unreadCount} unread notification${unreadCount > 1 ? "s" : ""}` + : "All caught up"} +

+
+
+ {unreadCount > 0 && ( + + )} +
+
+ +
+ {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) => ( + + + ); +} + +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; +}