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 && ( Escrow - )} - {template?.id === 'contributions' && ( - 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)' }} > -
+
+