From 74f6475ce64f662297d5b1467f71ecb13d840743 Mon Sep 17 00:00:00 2001 From: OluwapelumiElisha Date: Mon, 30 Mar 2026 11:19:18 +0100 Subject: [PATCH 1/2] #145 [Frontend] Build User Profile and Settings Page --- apps/frontend/app/profile/page.tsx | 274 ++++++++++++++++ apps/frontend/app/settings/page.tsx | 381 ++++++++++++++++++++++ apps/frontend/component/Providers.tsx | 1 + apps/frontend/component/layout/Navbar.tsx | 26 ++ apps/frontend/services/apiKey.ts | 37 +++ apps/frontend/services/escrowProfile.ts | 17 + apps/frontend/services/notification.ts | 31 +- apps/frontend/services/user.ts | 18 + apps/frontend/types/notification.ts | 25 ++ apps/frontend/types/user.ts | 18 + 10 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/app/profile/page.tsx create mode 100644 apps/frontend/app/settings/page.tsx create mode 100644 apps/frontend/services/apiKey.ts create mode 100644 apps/frontend/services/escrowProfile.ts create mode 100644 apps/frontend/services/user.ts create mode 100644 apps/frontend/types/user.ts diff --git a/apps/frontend/app/profile/page.tsx b/apps/frontend/app/profile/page.tsx new file mode 100644 index 0000000..89b0beb --- /dev/null +++ b/apps/frontend/app/profile/page.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Copy, ExternalLink, RefreshCcw } from 'lucide-react'; +import { useWallet as useWalletContext } from '@/app/contexts/WalletContext'; +import { WalletType } from '@/app/services/wallet'; +import { userService } from '@/services/user'; +import { escrowProfileService } from '@/services/escrowProfile'; +import { UserProfile } from '@/types/user'; + +const STATUS_LABELS = { + active: 'Active', + completed: 'Completed', + disputed: 'Disputed', +}; + +const walletLabels: Record = { + [WalletType.ALBEDO]: 'Albedo', + [WalletType.FREIGHTER]: 'Freighter', +}; + +const formatDate = (dateString?: string) => + dateString ? new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) : 'Unknown'; + +const ProfilePage = () => { + const { wallet, connect, getAvailableWallets, isConnecting, error } = useWalletContext(); + const [availableWallets, setAvailableWallets] = useState([]); + const [profile, setProfile] = useState(null); + const [escrowStats, setEscrowStats] = useState({ + totalCreated: 0, + active: 0, + completed: 0, + disputed: 0, + }); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [copied, setCopied] = useState(false); + + const publicKey = wallet?.publicKey ?? profile?.walletAddress; + const network = wallet?.network ?? process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet'; + const explorerUrl = publicKey + ? `https://stellar.expert/explorer/${network === 'mainnet' ? 'public' : 'testnet'}/account/${publicKey}` + : undefined; + + useEffect(() => { + const load = async () => { + try { + setLoading(true); + setLoadError(null); + + const [profileData, escrows] = await Promise.all([ + userService.getCurrentUser(), + escrowProfileService.fetchUserEscrows(1000), + ]); + + setProfile(profileData); + + const normalized = Array.isArray(escrows) ? escrows : []; + const created = normalized.filter( + (item) => + (item.creatorId && item.creatorId === profileData.walletAddress) || + (item.creatorAddress && item.creatorAddress === profileData.walletAddress), + ).length; + + const active = normalized.filter( + (item) => String(item.status).toLowerCase() === 'active', + ).length; + const completed = normalized.filter( + (item) => String(item.status).toLowerCase() === 'completed', + ).length; + const disputed = normalized.filter( + (item) => String(item.status).toLowerCase() === 'disputed', + ).length; + + setEscrowStats({ + totalCreated: created, + active, + completed, + disputed, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load profile'; + setLoadError(message); + } finally { + setLoading(false); + } + }; + + void load(); + }, []); + + useEffect(() => { + const fetchWallets = async () => { + try { + setAvailableWallets(await getAvailableWallets()); + } catch { + setAvailableWallets([]); + } + }; + + void fetchWallets(); + }, [getAvailableWallets]); + + useEffect(() => { + if (!copied) return; + const timeout = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timeout); + }, [copied]); + + const handleCopyAddress = async () => { + if (!publicKey) return; + await navigator.clipboard.writeText(publicKey); + setCopied(true); + }; + + const connectToWallet = async (walletType: WalletType) => { + try { + await connect(walletType); + } catch (err) { + console.error(err); + } + }; + + return ( +
+
+
+
+

Profile

+

User profile & account

+
+
+ + Open settings + + +
+
+ + {loading ? ( +
+ Loading profile details... +
+ ) : loadError ? ( +
+

Unable to load profile

+

{loadError}

+
+ ) : ( +
+
+
+
+
+

Account summary

+

Your connected wallet and profile details.

+
+ + {wallet?.walletType ? walletLabels[wallet.walletType] : 'Wallet not connected'} + +
+ +
+
+

Connected address

+
+
{publicKey ?? 'No connected wallet'}
+ {publicKey && ( +
+ + {explorerUrl && ( + + Explorer + + )} +
+ )} +
+
+ +
+

Account role

+

{profile?.role ?? 'USER'}

+

Member since

+

{formatDate(profile?.createdAt)}

+
+
+
+ +
+

Escrow statistics

+

Quick view of your escrow activity.

+ +
+
+

Total created

+

{escrowStats.totalCreated}

+
+
+

Active escrows

+

{escrowStats.active}

+
+
+

Completed escrows

+

{escrowStats.completed}

+
+
+

Disputed escrows

+

{escrowStats.disputed}

+
+
+
+
+ + +
+ )} +
+
+ ); +}; + +export default ProfilePage; diff --git a/apps/frontend/app/settings/page.tsx b/apps/frontend/app/settings/page.tsx new file mode 100644 index 0000000..14c4a98 --- /dev/null +++ b/apps/frontend/app/settings/page.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Check, Plus, RefreshCcw, ShieldCheck, Zap } from 'lucide-react'; +import { useWallet as useWalletContext } from '@/app/contexts/WalletContext'; +import { WalletType } from '@/app/services/wallet'; +import { notificationService } from '@/services/notification'; +import { apiKeyService } from '@/services/apiKey'; +import { + NotificationChannel, + NotificationEventType, + NotificationPreference, +} from '@/types/notification'; +import { ApiKeyItem } from '@/types/user'; + +const EVENT_TYPES: NotificationEventType[] = [ + 'ESCROW_CREATED', + 'ESCROW_FUNDED', + 'MILESTONE_RELEASED', + 'ESCROW_COMPLETED', + 'ESCROW_CANCELLED', + 'DISPUTE_RAISED', + 'DISPUTE_RESOLVED', + 'ESCROW_EXPIRED', + 'CONDITION_FULFILLED', + 'CONDITION_CONFIRMED', + 'EXPIRATION_WARNING', +]; + +const CHANNEL_LABELS: Record = { + email: 'Email', + webhook: 'Webhook', +}; + +const walletLabels: Record = { + [WalletType.ALBEDO]: 'Albedo', + [WalletType.FREIGHTER]: 'Freighter', +}; + +const defaultChannelPreferences = { + email: { enabled: true, eventTypes: [...EVENT_TYPES] }, + webhook: { enabled: true, eventTypes: [...EVENT_TYPES] }, +}; + +const SettingsPage = () => { + const { wallet, connect, disconnect, getAvailableWallets, isConnecting } = useWalletContext(); + const [availableWallets, setAvailableWallets] = useState([]); + const [preferences, setPreferences] = useState>(defaultChannelPreferences); + const [loadingPreferences, setLoadingPreferences] = useState(true); + const [savingPreferences, setSavingPreferences] = useState(false); + const [preferencesError, setPreferencesError] = useState(null); + const [apiKeys, setApiKeys] = useState([]); + const [creatingKey, setCreatingKey] = useState(false); + const [newApiKeyName, setNewApiKeyName] = useState(''); + const [createdKey, setCreatedKey] = useState(null); + const [apiKeyError, setApiKeyError] = useState(null); + + const network = wallet?.network ?? process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet'; + + useEffect(() => { + const loadPreferences = async () => { + try { + setLoadingPreferences(true); + setPreferencesError(null); + const response = await notificationService.getPreferences(); + const emailPref = response.find((item) => item.channel === 'email'); + const webhookPref = response.find((item) => item.channel === 'webhook'); + + setPreferences({ + email: emailPref + ? { enabled: emailPref.enabled, eventTypes: emailPref.eventTypes } + : defaultChannelPreferences.email, + webhook: webhookPref + ? { enabled: webhookPref.enabled, eventTypes: webhookPref.eventTypes } + : defaultChannelPreferences.webhook, + }); + } catch (err) { + setPreferencesError('Unable to load notification preferences.'); + } finally { + setLoadingPreferences(false); + } + }; + + const loadWallets = async () => { + try { + setAvailableWallets(await getAvailableWallets()); + } catch { + setAvailableWallets([]); + } + }; + + const loadApiKeys = async () => { + try { + const keys = await apiKeyService.listApiKeys(); + setApiKeys(keys); + } catch { + setApiKeyError('Failed to load API keys.'); + } + }; + + void loadPreferences(); + void loadWallets(); + void loadApiKeys(); + }, [getAvailableWallets]); + + const handleToggleEventType = (channel: NotificationChannel, eventType: NotificationEventType) => { + setPreferences((prev) => { + const selected = prev[channel].eventTypes.includes(eventType) + ? prev[channel].eventTypes.filter((item) => item !== eventType) + : [...prev[channel].eventTypes, eventType]; + + return { + ...prev, + [channel]: { + enabled: selected.length > 0, + eventTypes: selected, + }, + }; + }); + }; + + const handleSavePreferences = async () => { + setSavingPreferences(true); + setPreferencesError(null); + + try { + const payload = (['email', 'webhook'] as NotificationChannel[]).map((channel) => { + const preference = preferences[channel]; + return { + channel, + enabled: preference.enabled, + eventTypes: preference.enabled ? preference.eventTypes : EVENT_TYPES, + }; + }); + + await notificationService.updatePreferences(payload); + } catch (err) { + setPreferencesError('Failed to save notification preferences.'); + } finally { + setSavingPreferences(false); + } + }; + + const handleCreateApiKey = async () => { + if (!newApiKeyName.trim()) { + setApiKeyError('Please enter a name for the API key.'); + return; + } + + setCreatingKey(true); + setApiKeyError(null); + setCreatedKey(null); + + try { + const newKey = await apiKeyService.createApiKey(newApiKeyName.trim()); + setApiKeys((prev) => [newKey, ...prev]); + setCreatedKey(newKey.key ?? null); + setNewApiKeyName(''); + } catch { + setApiKeyError('Unable to create API key.'); + } finally { + setCreatingKey(false); + } + }; + + const handleRevokeApiKey = async (id: string) => { + try { + await apiKeyService.revokeApiKey(id); + setApiKeys((prev) => prev.filter((key) => key.id !== id)); + } catch { + setApiKeyError('Unable to revoke API key.'); + } + }; + + const handleConnectWallet = async (walletType: WalletType) => { + try { + await connect(walletType); + } catch { + // ignore connect errors in the UI for now + } + }; + + return ( +
+
+
+
+

Settings

+

Notification, API key, and wallet settings

+
+
+ + {network.toUpperCase()} network + +
+
+ +
+
+
+
+
+

Notification preferences

+

Toggle email and webhook delivery per event type.

+
+ +
+ + {loadingPreferences ? ( +
Loading preferences...
+ ) : ( +
+
+
Event
+
Email
+
Webhook
+
+ {EVENT_TYPES.map((eventType) => ( +
+
{eventType.replace(/_/g, ' ')}
+ {(['email', 'webhook'] as NotificationChannel[]).map((channel) => { + const active = preferences[channel].eventTypes.includes(eventType); + return ( + + ); + })} +
+ ))} +
+ )} + + {preferencesError && ( +
{preferencesError}
+ )} +
+ +
+
+
+

API key management

+

Create, list, and revoke API keys for backend access.

+
+ +
+ +
+ + setNewApiKeyName(event.target.value)} + className="rounded-2xl border border-slate-800 bg-slate-900 px-4 py-3 text-sm text-white outline-none transition focus:border-violet-500" + placeholder="e.g. My API key" + /> +
+ + {createdKey && ( +
+

API key created successfully. Copy it now — this value will not be shown again.

+
{createdKey}
+
+ )} + + {apiKeyError && ( +
{apiKeyError}
+ )} + +
+ {apiKeys.length === 0 ? ( +
No API keys available yet.
+ ) : ( +
+ {apiKeys.map((key) => ( +
+
+
+

{key.name}

+

Created {new Date(key.createdAt).toLocaleDateString()}

+
+ +
+

Status: {key.active ? 'Active' : 'Revoked'}

+
+ ))} +
+ )} +
+
+
+ + +
+
+
+ ); +}; + +export default SettingsPage; diff --git a/apps/frontend/component/Providers.tsx b/apps/frontend/component/Providers.tsx index 4131a79..6d145cf 100644 --- a/apps/frontend/component/Providers.tsx +++ b/apps/frontend/component/Providers.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ToastProvider } from '@/app/contexts/ToastProvider'; +import { WalletProvider } from '@/app/contexts/WalletContext'; export default function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ diff --git a/apps/frontend/component/layout/Navbar.tsx b/apps/frontend/component/layout/Navbar.tsx index 8be266f..ba02842 100644 --- a/apps/frontend/component/layout/Navbar.tsx +++ b/apps/frontend/component/layout/Navbar.tsx @@ -55,6 +55,18 @@ export default function Navbar(): JSX.Element { > Dashboard + + Profile + + + Settings + Dashboard + setIsMenuOpen(false)} + className="block text-gray-300 hover:text-white transition-colors" + > + Profile + + setIsMenuOpen(false)} + className="block text-gray-300 hover:text-white transition-colors" + > + Settings + setIsMenuOpen(false)} diff --git a/apps/frontend/services/apiKey.ts b/apps/frontend/services/apiKey.ts new file mode 100644 index 0000000..32ecf83 --- /dev/null +++ b/apps/frontend/services/apiKey.ts @@ -0,0 +1,37 @@ +import axios from 'axios'; +import { ApiKeyItem } from '@/types/user'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +const getAuthHeaders = () => { + const token = localStorage.getItem('authToken'); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +export const apiKeyService = { + async listApiKeys(): Promise { + const response = await axios.get(`${API_URL}/api-keys`, { + headers: getAuthHeaders(), + }); + return response.data as ApiKeyItem[]; + }, + + async createApiKey(name: string, rateLimitPerMinute?: number): Promise { + const payload = { name } as Record; + if (rateLimitPerMinute !== undefined) { + payload.rateLimitPerMinute = rateLimitPerMinute; + } + + const response = await axios.post(`${API_URL}/api-keys`, payload, { + headers: getAuthHeaders(), + }); + return response.data as ApiKeyItem; + }, + + async revokeApiKey(id: string): Promise { + const response = await axios.delete(`${API_URL}/api-keys/${id}`, { + headers: getAuthHeaders(), + }); + return response.data as ApiKeyItem; + }, +}; diff --git a/apps/frontend/services/escrowProfile.ts b/apps/frontend/services/escrowProfile.ts new file mode 100644 index 0000000..a59b376 --- /dev/null +++ b/apps/frontend/services/escrowProfile.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +const getAuthHeaders = () => { + const token = localStorage.getItem('authToken'); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +export const escrowProfileService = { + async fetchUserEscrows(limit = 1000): Promise { + const response = await axios.get(`${API_URL}/escrows?limit=${limit}`, { + headers: getAuthHeaders(), + }); + return response.data?.data ?? response.data ?? []; + }, +}; diff --git a/apps/frontend/services/notification.ts b/apps/frontend/services/notification.ts index 55e06fc..200f870 100644 --- a/apps/frontend/services/notification.ts +++ b/apps/frontend/services/notification.ts @@ -1,5 +1,10 @@ import axios from 'axios'; -import { Notification } from '@/types/notification'; +import { + Notification, + NotificationPreference, + NotificationChannel, + NotificationEventType, +} from '@/types/notification'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; @@ -23,6 +28,30 @@ export const notificationService = { return response.data; }, + async getPreferences(): Promise { + const response = await axios.get(`${API_URL}/notifications/preferences`, { + headers: getAuthHeaders(), + }); + return response.data; + }, + + async updatePreferences( + preferences: Array<{ + channel: NotificationChannel; + enabled: boolean; + eventTypes: NotificationEventType[]; + }>, + ): Promise { + const response = await axios.put( + `${API_URL}/notifications/preferences`, + preferences, + { + headers: getAuthHeaders(), + }, + ); + return response.data; + }, + async markAsRead(notificationId?: string): Promise { await axios.post( `${API_URL}/notifications/mark-as-read`, diff --git a/apps/frontend/services/user.ts b/apps/frontend/services/user.ts new file mode 100644 index 0000000..619669a --- /dev/null +++ b/apps/frontend/services/user.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import { UserProfile } from '@/types/user'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +const getAuthHeaders = () => { + const token = localStorage.getItem('authToken'); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +export const userService = { + async getCurrentUser(): Promise { + const response = await axios.get(`${API_URL}/auth/me`, { + headers: getAuthHeaders(), + }); + return response.data as UserProfile; + }, +}; diff --git a/apps/frontend/types/notification.ts b/apps/frontend/types/notification.ts index 98273d6..8d969d9 100644 --- a/apps/frontend/types/notification.ts +++ b/apps/frontend/types/notification.ts @@ -1,3 +1,18 @@ +export type NotificationChannel = 'email' | 'webhook'; + +export type NotificationEventType = + | 'ESCROW_CREATED' + | 'ESCROW_FUNDED' + | 'MILESTONE_RELEASED' + | 'ESCROW_COMPLETED' + | 'ESCROW_CANCELLED' + | 'DISPUTE_RAISED' + | 'DISPUTE_RESOLVED' + | 'ESCROW_EXPIRED' + | 'CONDITION_FULFILLED' + | 'CONDITION_CONFIRMED' + | 'EXPIRATION_WARNING'; + export interface Notification { id: string; userId: string; @@ -11,6 +26,16 @@ export interface Notification { updatedAt: string; } +export interface NotificationPreference { + id: string; + userId: string; + channel: NotificationChannel; + enabled: boolean; + eventTypes: NotificationEventType[]; + createdAt: string; + updatedAt: string; +} + export interface NotificationsResponse { notifications: Notification[]; unreadCount: number; diff --git a/apps/frontend/types/user.ts b/apps/frontend/types/user.ts new file mode 100644 index 0000000..d806eae --- /dev/null +++ b/apps/frontend/types/user.ts @@ -0,0 +1,18 @@ +export type UserRole = 'USER' | 'ADMIN' | 'SUPER_ADMIN'; + +export interface UserProfile { + id: string; + walletAddress: string; + isActive: boolean; + role?: UserRole; + createdAt: string; +} + +export interface ApiKeyItem { + id: string; + name: string; + key?: string; + active: boolean; + rateLimitPerMinute: number; + createdAt: string; +} From 9b4e6062964e67fcf6ba1c168c3174e14ab138e4 Mon Sep 17 00:00:00 2001 From: OluwapelumiElisha Date: Tue, 31 Mar 2026 14:17:35 +0100 Subject: [PATCH 2/2] #145 [Frontend] Build User Profile and Settings Page --- .github/workflows/frontend-ci.yml | 15 ++++++++++----- .../modules/auth/controllers/auth.controller.ts | 1 + apps/frontend/component/Providers.tsx | 10 ++++++---- apps/frontend/next.config.ts | 5 ++++- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 48bce1b..ce61b3a 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -28,14 +28,19 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - cache-dependency-path: apps/frontend/package-lock.json + cache: 'pnpm' + cache-dependency-path: apps/frontend/pnpm-lock.yaml + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Lint - run: npm run lint + run: pnpm run lint - name: Build - run: npm run build + run: pnpm run build diff --git a/apps/backend/src/modules/auth/controllers/auth.controller.ts b/apps/backend/src/modules/auth/controllers/auth.controller.ts index c43f625..199c21b 100644 --- a/apps/backend/src/modules/auth/controllers/auth.controller.ts +++ b/apps/backend/src/modules/auth/controllers/auth.controller.ts @@ -54,6 +54,7 @@ export class AuthController { id: user.id, walletAddress: user.walletAddress, isActive: user.isActive, + role: user.role, createdAt: user.createdAt, }; } diff --git a/apps/frontend/component/Providers.tsx b/apps/frontend/component/Providers.tsx index 6d145cf..be6c383 100644 --- a/apps/frontend/component/Providers.tsx +++ b/apps/frontend/component/Providers.tsx @@ -17,9 +17,11 @@ export default function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); -} \ No newline at end of file +} diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index e9ffa30..770f908 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -1,7 +1,10 @@ +import path from "path"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + root: path.join(__dirname), + }, }; export default nextConfig;