diff --git a/package-lock.json b/package-lock.json
index e2be551..cf89f77 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@noir-lang/noir_js": "1.0.0-beta.19",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -2447,6 +2448,61 @@
}
}
},
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+ "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
diff --git a/package.json b/package.json
index 4a861c3..d130fcc 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@creit.tech/stellar-wallets-kit": "^1.5.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
diff --git a/src/@types/templates.ts b/src/@types/templates.ts
index 6d4bd0a..3c5c164 100644
--- a/src/@types/templates.ts
+++ b/src/@types/templates.ts
@@ -12,4 +12,5 @@ export type CredentialTemplate = {
description: string;
vcType: string;
fields: TemplateField[];
+ iconSrc?: string;
};
diff --git a/src/app/dashboard/notifications/page.tsx b/src/app/dashboard/notifications/page.tsx
new file mode 100644
index 0000000..3b11282
--- /dev/null
+++ b/src/app/dashboard/notifications/page.tsx
@@ -0,0 +1,14 @@
+import { NotificationList } from '@/components/modules/notifications/ui/NotificationList';
+
+export const metadata = {
+ title: 'Notifications',
+};
+
+export default function NotificationsPage() {
+ return (
+
+
Notifications
+
+
+ );
+}
diff --git a/src/components/modules/issue/hooks/useCredentialTemplates.ts b/src/components/modules/issue/hooks/useCredentialTemplates.ts
index ce7aa38..b6ddec0 100644
--- a/src/components/modules/issue/hooks/useCredentialTemplates.ts
+++ b/src/components/modules/issue/hooks/useCredentialTemplates.ts
@@ -26,6 +26,7 @@ export function useCredentialTemplates() {
title: 'Escrow',
description: 'Programmable escrow credential linked to Trustless Work.',
vcType: 'EscrowCredential',
+ iconSrc: '/tw.png',
fields: [
{
key: 'subject',
@@ -48,6 +49,7 @@ export function useCredentialTemplates() {
title: 'Contributions',
description: 'Contributions count to a specific project.',
vcType: 'ContributionsCredential',
+ iconSrc: '/gf.png',
fields: [
{
key: 'subject',
@@ -120,6 +122,156 @@ export function useCredentialTemplates() {
{ key: 'expirationDate', label: 'Expiration Date', type: 'date' },
],
},
+ {
+ id: 'event_attendance',
+ title: 'Event Attendance',
+ description: 'Credential for event or conference attendance.',
+ vcType: 'EventAttendanceCredential',
+ fields: [
+ {
+ key: 'subject',
+ label: 'Subject DID',
+ type: 'did',
+ required: true,
+ placeholder: 'Wallet (G...) – we derive DID',
+ },
+ { key: 'eventName', label: 'Event Name', type: 'text', required: true },
+ { key: 'eventDate', label: 'Event Date', type: 'date', required: true },
+ { key: 'location', label: 'Location', type: 'text', required: true },
+ { key: 'role', label: 'Role', type: 'text', required: false, placeholder: 'Attendee' },
+ { key: 'organizer', label: 'Organizer', type: 'text', required: false },
+ { key: 'issueDate', label: 'Issue Date', type: 'date', required: true },
+ { key: 'expirationDate', label: 'Expiration Date', type: 'date' },
+ ],
+ },
+ {
+ id: 'payment_receipt',
+ title: 'Payment Receipt',
+ description: 'Credential for payment or transaction receipts.',
+ vcType: 'PaymentReceiptCredential',
+ fields: [
+ {
+ key: 'subject',
+ label: 'Subject DID',
+ type: 'did',
+ required: true,
+ placeholder: 'Wallet (G...) – we derive DID',
+ },
+ { key: 'merchant', label: 'Merchant', type: 'text', required: true },
+ { key: 'invoiceId', label: 'Invoice ID', type: 'text', required: true },
+ { key: 'amount', label: 'Amount', type: 'number', required: true },
+ { key: 'asset', label: 'Asset', type: 'text', required: true, placeholder: 'USDC' },
+ { key: 'paidAt', label: 'Payment Date', type: 'date', required: true },
+ { key: 'txHash', label: 'Transaction Hash', type: 'text', required: false },
+ { key: 'issueDate', label: 'Issue Date', type: 'date', required: true },
+ ],
+ },
+ {
+ id: 'kyc_verification',
+ title: 'KYC Verification',
+ description: 'Identity verification credential.',
+ vcType: 'KYCVerificationCredential',
+ fields: [
+ {
+ key: 'subject',
+ label: 'Subject DID',
+ type: 'did',
+ required: true,
+ placeholder: 'Wallet (G...) – we derive DID',
+ },
+ { key: 'fullName', label: 'Full Name', type: 'text', required: true },
+ { key: 'country', label: 'Country', type: 'text', required: true },
+ {
+ key: 'verificationLevel',
+ label: 'Verification Level',
+ type: 'text',
+ required: true,
+ placeholder: 'Basic | Advanced | Full',
+ },
+ { key: 'verifiedBy', label: 'Verified By', type: 'text', required: true },
+ { key: 'issueDate', label: 'Issue Date', type: 'date', required: true },
+ { key: 'expirationDate', label: 'Expiration Date', type: 'date' },
+ ],
+ },
+ {
+ id: 'access_pass',
+ title: 'Access Pass',
+ description: 'Credential granting access to resources or services.',
+ vcType: 'AccessPassCredential',
+ fields: [
+ {
+ key: 'subject',
+ label: 'Subject DID',
+ type: 'did',
+ required: true,
+ placeholder: 'Wallet (G...) – we derive DID',
+ },
+ { key: 'resource', label: 'Resource', type: 'text', required: true },
+ {
+ key: 'permission',
+ label: 'Permission',
+ type: 'text',
+ required: true,
+ placeholder: 'read | write | admin',
+ },
+ { key: 'scope', label: 'Scope', type: 'text', required: false },
+ { key: 'issueDate', label: 'Issue Date', type: 'date', required: true },
+ { key: 'expirationDate', label: 'Expiration Date', type: 'date' },
+ ],
+ },
+ {
+ id: 'skill_badge',
+ title: 'Skill Badge',
+ description: 'Credential recognizing a specific skill or competency.',
+ vcType: 'SkillBadgeCredential',
+ fields: [
+ {
+ key: 'subject',
+ label: 'Subject DID',
+ type: 'did',
+ required: true,
+ placeholder: 'Wallet (G...) – we derive DID',
+ },
+ { key: 'skill', label: 'Skill', type: 'text', required: true },
+ {
+ key: 'level',
+ label: 'Level',
+ type: 'text',
+ required: true,
+ placeholder: 'Beginner | Intermediate | Expert',
+ },
+ { key: 'issuer', label: 'Issuer', type: 'text', required: true },
+ { key: 'evidenceUrl', label: 'Evidence URL', type: 'text', required: false },
+ { key: 'issueDate', label: 'Issue Date', type: 'date', required: true },
+ ],
+ },
+ {
+ id: 'warranty',
+ title: 'Warranty',
+ description: 'Product warranty or support credential.',
+ vcType: 'WarrantyCredential',
+ fields: [
+ {
+ key: 'subject',
+ label: 'Subject DID',
+ type: 'did',
+ required: true,
+ placeholder: 'Wallet (G...) – we derive DID',
+ },
+ { key: 'productName', label: 'Product Name', type: 'text', required: true },
+ { key: 'serialNumber', label: 'Serial Number', type: 'text', required: true },
+ { key: 'purchaseDate', label: 'Purchase Date', type: 'date', required: true },
+ {
+ key: 'coverage',
+ label: 'Coverage',
+ type: 'text',
+ required: true,
+ placeholder: 'Full | Limited',
+ },
+ { key: 'issueDate', label: 'Issue Date', type: 'date', required: true },
+ { key: 'expirationDate', label: 'Expiration Date', type: 'date' },
+ ],
+ },
];
const templates: CredentialTemplate[] = [...builtIn, ...customTemplates];
diff --git a/src/components/modules/issue/ui/DynamicIssueForm.tsx b/src/components/modules/issue/ui/DynamicIssueForm.tsx
index 787cdda..60c61e5 100644
--- a/src/components/modules/issue/ui/DynamicIssueForm.tsx
+++ b/src/components/modules/issue/ui/DynamicIssueForm.tsx
@@ -87,20 +87,10 @@ export default function DynamicIssueForm({
{template ? `Create credential ${template.title.toLowerCase()}` : 'Form'}
- {template?.id === 'escrow' && (
+ {template?.iconSrc && (
- )}
- {template?.id === 'contributions' && (
- {
+ const vcId = notification.metadata?.vc_id;
+ const action = vcId
+ ? {
+ label: 'View credential' as const,
+ onClick: () => router.push(`/credential/${vcId}`),
+ }
+ : {
+ label: 'View all' as const,
+ onClick: () => router.push('/dashboard/notifications'),
+ };
+
+ const copy = NOTIFICATION_TYPE_LABEL[notification.type];
+ const title = copy?.title ?? 'Notification';
+ const description = copy?.message ?? notification.message;
+
+ switch (notification.type) {
+ case 'credential_received':
+ toast.success(title, { description, action });
+ break;
+ case 'credential_verified':
+ toast.info(title, { description, action });
+ break;
+ case 'credential_expiring_soon':
+ toast.info(title, { description, action });
+ break;
+ case 'credential_revoked':
+ toast.warning(title, { description, action });
+ break;
+ case 'issuer_authorized':
+ toast.info(title, { description, action });
+ break;
+ case 'issuer_revoked':
+ toast.info(title, { description, action });
+ break;
+ default:
+ toast(title, { description, action });
+ }
+ },
+ [router]
+ );
+
+ return { showNotificationToast };
+}
diff --git a/src/components/modules/notifications/hooks/useNotifications.ts b/src/components/modules/notifications/hooks/useNotifications.ts
new file mode 100644
index 0000000..b865785
--- /dev/null
+++ b/src/components/modules/notifications/hooks/useNotifications.ts
@@ -0,0 +1,105 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { useWalletContext } from '@/providers/wallet.provider';
+import { useActaApiKey } from '@/components/modules/vault/hooks/use-acta-api-key';
+import { actaFetchJson } from '@/lib/actaApi';
+import type { Notification } from '../types';
+
+/** The API may return a bare array or a wrapped object like { data: [...], count: N }. */
+function extractNotifications(raw: unknown): Notification[] {
+ if (Array.isArray(raw)) return raw as Notification[];
+ if (raw && typeof raw === 'object') {
+ const obj = raw as Record;
+ if (Array.isArray(obj.data)) return obj.data as Notification[];
+ if (Array.isArray(obj.notifications)) return obj.notifications as Notification[];
+ if (Array.isArray(obj.items)) return obj.items as Notification[];
+ }
+ return [];
+}
+
+export interface UseNotificationsOptions {
+ unreadOnly?: boolean;
+ limit?: number;
+ offset?: number;
+}
+
+function buildListPath(
+ walletAddress: string,
+ network: string,
+ options: UseNotificationsOptions
+): string {
+ const params = new URLSearchParams({
+ wallet_address: walletAddress,
+ network,
+ });
+ if (options.limit != null) params.set('limit', String(options.limit));
+ if (options.offset != null) params.set('offset', String(options.offset));
+ if (options.unreadOnly === true) params.set('unread_only', 'true');
+ return `/notifications?${params.toString()}`;
+}
+
+function buildUnreadCountPath(walletAddress: string, network: string): string {
+ return `/notifications/unread-count?wallet_address=${encodeURIComponent(walletAddress)}&network=${network}`;
+}
+
+export function useNotifications(options: UseNotificationsOptions = {}) {
+ const { walletAddress } = useWalletContext();
+ const { network, apiKey } = useActaApiKey();
+ const enabled = !!(walletAddress && apiKey?.trim());
+
+ const listQuery = useQuery({
+ queryKey: [
+ 'notifications',
+ 'list',
+ walletAddress,
+ network,
+ options.unreadOnly,
+ options.limit,
+ options.offset,
+ ],
+ queryFn: async () => {
+ if (!walletAddress || !apiKey?.trim()) return [];
+ const path = buildListPath(walletAddress, network, options);
+ const raw = await actaFetchJson({
+ network,
+ apiKey: apiKey.trim(),
+ method: 'GET',
+ path,
+ });
+ return extractNotifications(raw);
+ },
+ enabled,
+ staleTime: 30_000,
+ });
+
+ const unreadCountQuery = useQuery<{ count: number }>({
+ queryKey: ['notifications', 'unreadCount', walletAddress, network],
+ queryFn: async () => {
+ if (!walletAddress || !apiKey?.trim()) return { count: 0 };
+ const path = buildUnreadCountPath(walletAddress, network);
+ return actaFetchJson<{ count: number }>({
+ network,
+ apiKey: apiKey.trim(),
+ method: 'GET',
+ path,
+ });
+ },
+ enabled,
+ staleTime: 30_000,
+ });
+
+ const notifications = listQuery.data ?? [];
+ const unreadCount = unreadCountQuery.data?.count ?? 0;
+ const loading = listQuery.isLoading || unreadCountQuery.isLoading;
+ const error = listQuery.error ?? unreadCountQuery.error;
+
+ return {
+ notifications,
+ unreadCount,
+ loading,
+ error,
+ refetchNotifications: listQuery.refetch,
+ refetchUnreadCount: unreadCountQuery.refetch,
+ };
+}
diff --git a/src/components/modules/notifications/hooks/useNotificationsActions.ts b/src/components/modules/notifications/hooks/useNotificationsActions.ts
new file mode 100644
index 0000000..729855e
--- /dev/null
+++ b/src/components/modules/notifications/hooks/useNotificationsActions.ts
@@ -0,0 +1,38 @@
+'use client';
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useWalletContext } from '@/providers/wallet.provider';
+import { useActaApiKey } from '@/components/modules/vault/hooks/use-acta-api-key';
+import { getActaApiBaseUrl } from '@/lib/actaApi';
+
+export function useNotificationsActions() {
+ const { walletAddress } = useWalletContext();
+ const { network, apiKey } = useActaApiKey();
+ const queryClient = useQueryClient();
+
+ const markAsReadMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (!walletAddress || !apiKey?.trim()) {
+ throw new Error('Wallet and API key required');
+ }
+ const base = getActaApiBaseUrl(network);
+ const url = `${base}/notifications/${encodeURIComponent(id)}/read?wallet_address=${encodeURIComponent(walletAddress)}`;
+ const resp = await fetch(url, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'X-ACTA-Key': apiKey.trim() },
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ return resp.json().catch(() => ({}));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'unreadCount'] });
+ },
+ });
+
+ return {
+ markAsRead: markAsReadMutation.mutateAsync,
+ markAsReadPending: markAsReadMutation.isPending,
+ markAsReadError: markAsReadMutation.error,
+ };
+}
diff --git a/src/components/modules/notifications/hooks/useNotificationsRealtime.ts b/src/components/modules/notifications/hooks/useNotificationsRealtime.ts
new file mode 100644
index 0000000..51efda0
--- /dev/null
+++ b/src/components/modules/notifications/hooks/useNotificationsRealtime.ts
@@ -0,0 +1,77 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { usePathname } from 'next/navigation';
+import { useQuery } from '@tanstack/react-query';
+import { useWalletContext } from '@/providers/wallet.provider';
+import { useActaApiKey } from '@/components/modules/vault/hooks/use-acta-api-key';
+import { actaFetchJson } from '@/lib/actaApi';
+import type { Notification } from '../types';
+import { useNotificationToast } from './useNotificationToast';
+
+function extractNotifications(raw: unknown): Notification[] {
+ if (Array.isArray(raw)) return raw as Notification[];
+ if (raw && typeof raw === 'object') {
+ const obj = raw as Record;
+ if (Array.isArray(obj.data)) return obj.data as Notification[];
+ if (Array.isArray(obj.notifications)) return obj.notifications as Notification[];
+ if (Array.isArray(obj.items)) return obj.items as Notification[];
+ }
+ return [];
+}
+
+function buildListPath(walletAddress: string, network: string, limit: number): string {
+ const params = new URLSearchParams({
+ wallet_address: walletAddress,
+ network,
+ unread_only: 'true',
+ limit: String(limit),
+ });
+ return `/notifications?${params.toString()}`;
+}
+
+export function useNotificationsRealtime() {
+ const pathname = usePathname();
+ const { walletAddress } = useWalletContext();
+ const { network, apiKey } = useActaApiKey();
+ const { showNotificationToast } = useNotificationToast();
+ const seenIdsRef = useRef>(new Set());
+ const hasInitializedRef = useRef(false);
+
+ const enabled =
+ !!(walletAddress && apiKey?.trim()) &&
+ typeof pathname === 'string' &&
+ pathname.startsWith('/dashboard');
+
+ const query = useQuery({
+ queryKey: ['notifications', 'realtime', walletAddress, network],
+ queryFn: async () => {
+ if (!walletAddress || !apiKey?.trim()) return [];
+ const path = buildListPath(walletAddress, network, 20);
+ const raw = await actaFetchJson({
+ network,
+ apiKey: apiKey.trim(),
+ method: 'GET',
+ path,
+ });
+ return extractNotifications(raw);
+ },
+ enabled,
+ refetchInterval: 20_000,
+ staleTime: 15_000,
+ });
+
+ useEffect(() => {
+ if (!query.data || !enabled) return;
+ const seen = seenIdsRef.current;
+ const initialized = hasInitializedRef.current;
+ for (const notification of query.data) {
+ if (seen.has(notification.id)) continue;
+ seen.add(notification.id);
+ if (initialized) {
+ showNotificationToast(notification);
+ }
+ }
+ hasInitializedRef.current = true;
+ }, [query.data, enabled, showNotificationToast]);
+}
diff --git a/src/components/modules/notifications/types.ts b/src/components/modules/notifications/types.ts
new file mode 100644
index 0000000..dced57d
--- /dev/null
+++ b/src/components/modules/notifications/types.ts
@@ -0,0 +1,57 @@
+/**
+ * Notification types supported by the ACTA Notifications API.
+ */
+export type NotificationType =
+ | 'credential_received'
+ | 'credential_verified'
+ | 'credential_expiring_soon'
+ | 'credential_revoked'
+ | 'issuer_authorized'
+ | 'issuer_revoked';
+
+/** Canonical English copy for each notification type. */
+export const NOTIFICATION_TYPE_LABEL: Record =
+ {
+ credential_received: {
+ title: 'New Credential Received',
+ message: 'You have received a new verifiable credential in your vault.',
+ },
+ credential_verified: {
+ title: 'Credential Verified',
+ message: 'Your credential has been successfully verified.',
+ },
+ credential_expiring_soon: {
+ title: 'Credential Expiring Soon',
+ message: 'One of your credentials is expiring soon. Review it in your vault.',
+ },
+ credential_revoked: {
+ title: 'Credential Revoked',
+ message: 'One of your credentials has been revoked.',
+ },
+ issuer_authorized: {
+ title: 'Issuer Authorized',
+ message: 'A new issuer has been authorized for your vault.',
+ },
+ issuer_revoked: {
+ title: 'Issuer Authorization Revoked',
+ message: 'An issuer authorization has been removed from your vault.',
+ },
+ };
+
+export interface NotificationMetadata {
+ vc_id?: string;
+ issuer?: string;
+ [key: string]: unknown;
+}
+
+export interface Notification {
+ id: string;
+ wallet_address: string;
+ network: 'mainnet' | 'testnet';
+ type: NotificationType;
+ title: string;
+ message: string;
+ metadata?: NotificationMetadata;
+ read_at: string | null;
+ created_at: string;
+}
diff --git a/src/components/modules/notifications/ui/NotificationBell.tsx b/src/components/modules/notifications/ui/NotificationBell.tsx
new file mode 100644
index 0000000..8721c1e
--- /dev/null
+++ b/src/components/modules/notifications/ui/NotificationBell.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { Bell } from 'lucide-react';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { useNotifications } from '../hooks/useNotifications';
+import { NotificationPreview } from './NotificationPreview';
+
+export function NotificationBell() {
+ const { unreadCount, refetchNotifications } = useNotifications({
+ limit: 8,
+ unreadOnly: false,
+ });
+
+ return (
+ {
+ if (open) void refetchNotifications();
+ }}
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/modules/notifications/ui/NotificationItem.tsx b/src/components/modules/notifications/ui/NotificationItem.tsx
new file mode 100644
index 0000000..2c71168
--- /dev/null
+++ b/src/components/modules/notifications/ui/NotificationItem.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import React from 'react';
+import {
+ Bell,
+ CheckCircle2,
+ Clock,
+ ShieldCheck,
+ ShieldOff,
+ UserMinus,
+ UserPlus,
+} from 'lucide-react';
+import type { Notification, NotificationType } from '../types';
+import { NOTIFICATION_TYPE_LABEL } from '../types';
+import { formatRelativeTime } from './formatRelativeTime';
+import { cn } from '@/lib/utils';
+
+function getIconNode(type: NotificationType): React.ReactNode {
+ const iconClass = cn(
+ 'h-4 w-4',
+ type === 'credential_received' && 'text-emerald-500',
+ type === 'credential_verified' && 'text-blue-500',
+ type === 'credential_expiring_soon' && 'text-amber-500',
+ type === 'credential_revoked' && 'text-red-500'
+ );
+ switch (type) {
+ case 'credential_received':
+ return ;
+ case 'credential_verified':
+ return ;
+ case 'credential_expiring_soon':
+ return ;
+ case 'credential_revoked':
+ return ;
+ case 'issuer_authorized':
+ return ;
+ case 'issuer_revoked':
+ return ;
+ default:
+ return ;
+ }
+}
+
+export interface NotificationItemProps {
+ notification: Notification;
+ onMarkAsRead?: (id: string) => void;
+ onClick?: () => void;
+ compact?: boolean;
+}
+
+export function NotificationItem({
+ notification,
+ onMarkAsRead,
+ onClick,
+ compact = false,
+}: NotificationItemProps) {
+ const isUnread = !notification.read_at;
+ const iconNode = getIconNode(notification.type);
+ const time = formatRelativeTime(notification.created_at);
+ const copy = NOTIFICATION_TYPE_LABEL[notification.type];
+ const typeLabel = copy?.title ?? 'Notification';
+ const typeMessage = copy?.message ?? notification.message;
+
+ const content = (
+ <>
+ {iconNode}
+
+
+
+ {typeLabel}
+
+
{time}
+
+
+ {typeMessage}
+
+
+ {isUnread && onMarkAsRead && (
+
+ )}
+ >
+ );
+
+ const wrapperClass = cn(
+ 'flex w-full gap-3 rounded-lg p-3 text-left transition-colors',
+ (onClick || isUnread) && 'bg-zinc-800/50',
+ onClick && 'cursor-pointer hover:bg-zinc-800/70'
+ );
+
+ return (
+ {
+ if (e.key === 'Enter' || e.key === ' ') onClick();
+ }
+ : undefined
+ }
+ >
+ {content}
+
+ );
+}
diff --git a/src/components/modules/notifications/ui/NotificationList.tsx b/src/components/modules/notifications/ui/NotificationList.tsx
new file mode 100644
index 0000000..e5a1936
--- /dev/null
+++ b/src/components/modules/notifications/ui/NotificationList.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useNotifications } from '../hooks/useNotifications';
+import { useNotificationsActions } from '../hooks/useNotificationsActions';
+import { NotificationItem } from './NotificationItem';
+
+const PAGE_SIZE = 20;
+
+export function NotificationList() {
+ const router = useRouter();
+ const [unreadOnly, setUnreadOnly] = useState(false);
+ const { notifications, loading, error } = useNotifications({
+ unreadOnly,
+ limit: PAGE_SIZE,
+ offset: 0,
+ });
+ const { markAsRead } = useNotificationsActions();
+
+ const handleItemClick = (vcId: string | undefined) => {
+ if (vcId) {
+ router.push(`/credential/${vcId}`);
+ } else {
+ router.push('/dashboard/notifications');
+ }
+ };
+
+ if (error) {
+ return (
+
+ {error instanceof Error ? error.message : 'Failed to load notifications'}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {loading ? (
+
Loading notifications…
+ ) : notifications.length === 0 ? (
+
+ {unreadOnly ? 'No unread notifications' : 'No notifications yet'}
+
+ ) : (
+
+ {notifications.map((notification) => (
+ handleItemClick(notification.metadata?.vc_id)}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/modules/notifications/ui/NotificationPreview.tsx b/src/components/modules/notifications/ui/NotificationPreview.tsx
new file mode 100644
index 0000000..0524750
--- /dev/null
+++ b/src/components/modules/notifications/ui/NotificationPreview.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import Link from 'next/link';
+import { useNotifications } from '../hooks/useNotifications';
+import { NotificationItem } from './NotificationItem';
+
+const PREVIEW_LIMIT = 8;
+
+export function NotificationPreview() {
+ const { notifications, loading } = useNotifications({
+ limit: PREVIEW_LIMIT,
+ unreadOnly: false,
+ });
+
+ const previewList = notifications.slice(0, PREVIEW_LIMIT);
+
+ return (
+
+
+ {loading ? (
+
Loading…
+ ) : previewList.length === 0 ? (
+
No notifications
+ ) : (
+
+ {previewList.map((notification) => (
+
+ ))}
+
+ )}
+
+ {previewList.length > 0 && (
+
+ View all
+
+ )}
+
+ );
+}
diff --git a/src/components/modules/notifications/ui/NotificationsRealtimeEffect.tsx b/src/components/modules/notifications/ui/NotificationsRealtimeEffect.tsx
new file mode 100644
index 0000000..d9d1b66
--- /dev/null
+++ b/src/components/modules/notifications/ui/NotificationsRealtimeEffect.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { useNotificationsRealtime } from '../hooks/useNotificationsRealtime';
+
+/**
+ * Renders nothing; runs polling and shows Sonner toasts for new notifications
+ * when the user is on a dashboard route. Mount only when pathname starts with /dashboard.
+ */
+export function NotificationsRealtimeEffect() {
+ useNotificationsRealtime();
+ return null;
+}
diff --git a/src/components/modules/notifications/ui/formatRelativeTime.ts b/src/components/modules/notifications/ui/formatRelativeTime.ts
new file mode 100644
index 0000000..57ff46c
--- /dev/null
+++ b/src/components/modules/notifications/ui/formatRelativeTime.ts
@@ -0,0 +1,19 @@
+export function formatRelativeTime(isoString: string): string {
+ try {
+ const date = new Date(isoString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) return 'Just now';
+ if (diffMin < 60) return `${diffMin} min ago`;
+ if (diffHour < 24) return `${diffHour}h ago`;
+ if (diffDay < 7) return `${diffDay}d ago`;
+ return date.toLocaleDateString();
+ } catch {
+ return '';
+ }
+}
diff --git a/src/components/ui/mobile-bottom-nav.tsx b/src/components/ui/mobile-bottom-nav.tsx
index 4b0bc3c..b79e7f7 100644
--- a/src/components/ui/mobile-bottom-nav.tsx
+++ b/src/components/ui/mobile-bottom-nav.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { Home, FilePlus, IdCard, User } from 'lucide-react';
+import { Home, FilePlus, IdCard, User, Bell } from 'lucide-react';
import type { ComponentType } from 'react';
function NavItem({
@@ -40,10 +40,11 @@ export default function MobileBottomNav() {
className="mx-auto max-w-7xl px-2 py-2"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
-
+
+