diff --git a/.env.example b/.env.example index d347971..3c517aa 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,6 @@ STELLAR_NETWORK=TESTNET STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org SOROBAN_RPC_URL=https://soroban-testnet.stellar.org TRUSTFLOW_CONTRACT_ID= +PROFILE_API_BASE_URL= JWT_SECRET=change-me-in-production PORT=3001 diff --git a/README.md b/README.md index cafc2fe..e2d5579 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ Full page sections wired to on-chain data. - The project currently uses the Next.js Pages Router. The Freelancer Leaderboard is implemented at `pages/leaderboard.tsx` to match the existing routing convention. - The leaderboard is backed by typed local sample data until the protocol exposes a public ranking endpoint or indexer query. The UI sorts the same dataset by total escrow earnings or reputation score. +- The profile page fetches reputation, bio, and past gigs from `GET /api/profile`, which proxies to `PROFILE_API_BASE_URL` when configured and falls back to typed mock data for local/dev environments. - No new runtime dependencies were added for this feature. --- diff --git a/hooks/index.ts b/hooks/index.ts index e5b6997..09793c9 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -2,3 +2,4 @@ export * from "./useAccount"; export * from "./useIsMounted"; export * from './useSubscription'; export * from './useUSDCPrice'; +export * from './useUserProfile'; diff --git a/hooks/useUserProfile.ts b/hooks/useUserProfile.ts new file mode 100644 index 0000000..966f113 --- /dev/null +++ b/hooks/useUserProfile.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { UserProfileResponse } from '../pages/api/profile' + +type ProfileStatus = 'idle' | 'loading' | 'success' | 'error' + +interface UseUserProfileResult { + profile: UserProfileResponse | null + status: ProfileStatus + error: string | null + refetch: () => Promise +} + +export function useUserProfile(walletAddress?: string): UseUserProfileResult { + const [profile, setProfile] = useState(null) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + + const query = useMemo(() => { + const value = walletAddress?.trim() + if (!value) { + return '' + } + return `?walletAddress=${encodeURIComponent(value)}` + }, [walletAddress]) + + const fetchProfile = useCallback(async () => { + setStatus('loading') + setError(null) + + try { + const response = await fetch(`/api/profile${query}`) + if (!response.ok) { + throw new Error(`Failed to load profile: ${response.status}`) + } + + const payload = (await response.json()) as UserProfileResponse + setProfile(payload) + setStatus('success') + } catch { + setProfile(null) + setStatus('error') + setError('Unable to load profile data. Please try again.') + } + }, [query]) + + useEffect(() => { + fetchProfile() + }, [fetchProfile]) + + return { + profile, + status, + error, + refetch: fetchProfile, + } +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 7f457ef..9625441 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6,6 +6,7 @@ "dashboard": "Dashboard", "escrow": "Escrow", "launchApp": "Launch App", + "connectWallet": "Connect Wallet", "openMenu": "Open menu", "closeMenu": "Close menu" }, diff --git a/messages/es.json b/messages/es.json index b0c32c6..dfacfe7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6,6 +6,7 @@ "dashboard": "Panel", "escrow": "Depósito en garantía", "launchApp": "Abrir app", + "connectWallet": "Conectar billetera", "openMenu": "Abrir menú", "closeMenu": "Cerrar menú" }, diff --git a/pages/api/profile.ts b/pages/api/profile.ts new file mode 100644 index 0000000..6bb7729 --- /dev/null +++ b/pages/api/profile.ts @@ -0,0 +1,155 @@ +import type { NextApiRequest, NextApiResponse } from 'next' + +export interface ProfileGig { + id: string + title: string + client: string + completedAt: string + payoutUSDC: number + rating: number +} + +export interface ProfileReputation { + score: number + totalGigs: number + completionRate: number + averageRating: number +} + +export interface UserProfileResponse { + walletAddress: string + displayName: string + bio: string + reputation: ProfileReputation + pastGigs: ProfileGig[] + source: 'backend' | 'mock' +} + +interface UserProfileError { + error: string +} + +interface BackendProfileResponse { + walletAddress: string + displayName: string + bio: string + reputation: ProfileReputation + pastGigs: ProfileGig[] +} + +function getMockProfile(walletAddress: string): UserProfileResponse { + return { + walletAddress, + displayName: 'Freelancer', + bio: 'Product-focused freelancer helping teams ship reliable web3 experiences with clear delivery milestones.', + reputation: { + score: 92, + totalGigs: 5, + completionRate: 100, + averageRating: 4.8, + }, + pastGigs: [ + { + id: 'gig-001', + title: 'Smart Contract Integration for Milestone Escrow', + client: 'Nova Labs', + completedAt: '2026-05-17T09:30:00.000Z', + payoutUSDC: 1200, + rating: 5, + }, + { + id: 'gig-002', + title: 'Responsive Dashboard UI Refactor', + client: 'Acme Studio', + completedAt: '2026-04-06T14:20:00.000Z', + payoutUSDC: 850, + rating: 4.6, + }, + { + id: 'gig-003', + title: 'Dispute Evidence Upload Flow', + client: 'Orbit Works', + completedAt: '2026-02-26T11:10:00.000Z', + payoutUSDC: 640, + rating: 4.9, + }, + ], + source: 'mock', + } +} + +function parseNumber(value: unknown): number { + if (typeof value === 'number') { + return value + } + if (typeof value === 'string') { + const parsed = Number(value) + return Number.isNaN(parsed) ? 0 : parsed + } + return 0 +} + +function normalizeBackendResponse(payload: BackendProfileResponse): UserProfileResponse { + return { + walletAddress: payload.walletAddress, + displayName: payload.displayName, + bio: payload.bio, + reputation: { + score: parseNumber(payload.reputation?.score), + totalGigs: parseNumber(payload.reputation?.totalGigs), + completionRate: parseNumber(payload.reputation?.completionRate), + averageRating: parseNumber(payload.reputation?.averageRating), + }, + pastGigs: (payload.pastGigs ?? []).map(gig => ({ + id: gig.id, + title: gig.title, + client: gig.client, + completedAt: gig.completedAt, + payoutUSDC: parseNumber(gig.payoutUSDC), + rating: parseNumber(gig.rating), + })), + source: 'backend', + } +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + res.setHeader('Cache-Control', 's-maxage=30, stale-while-revalidate=120') + + const walletAddress = + typeof req.query.walletAddress === 'string' && req.query.walletAddress.trim().length > 0 + ? req.query.walletAddress + : 'unknown' + + const backendBaseUrl = process.env.PROFILE_API_BASE_URL + if (!backendBaseUrl) { + return res.status(200).json(getMockProfile(walletAddress)) + } + + try { + const url = new URL('/profiles', backendBaseUrl) + url.searchParams.set('walletAddress', walletAddress) + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + return res.status(200).json(getMockProfile(walletAddress)) + } + + const payload = (await response.json()) as BackendProfileResponse + return res.status(200).json(normalizeBackendResponse(payload)) + } catch { + return res.status(200).json(getMockProfile(walletAddress)) + } +} \ No newline at end of file diff --git a/pages/dashboard/profile.tsx b/pages/dashboard/profile.tsx index 7d21452..96985d3 100644 --- a/pages/dashboard/profile.tsx +++ b/pages/dashboard/profile.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { Navbar } from '../../components/organisms' import { useState } from 'react' import { useRouter } from 'next/router' +import { useAccount, useUserProfile } from '../../hooks' interface NavItem { label: string @@ -21,9 +22,18 @@ const NAV_ITEMS: NavItem[] = [ const Profile: NextPage = () => { const router = useRouter() + const account = useAccount() + const { profile, status, error, refetch } = useUserProfile(account?.address) const [sidebarOpen, setSidebarOpen] = useState(false) const isActive = (href: string) => router.pathname === href + const formattedDate = (date: string) => + new Date(date).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + return ( <> @@ -78,37 +88,113 @@ const Profile: NextPage = () => {
👤
-

Anonymous User

-

Connect wallet to load profile

- +

+ {profile?.displayName ?? 'Freelancer'} +

+

+ {account?.displayName ?? 'Connect wallet to load your account profile'} +

+

+ {status === 'success' ? `Source: ${profile?.source ?? 'unknown'}` : 'Loading profile source...'} +

+ {!account && ( + + )}
+
+

Bio

+ {status === 'loading' && ( +

Loading bio...

+ )} + {status === 'error' && ( +
+

{error}

+ +
+ )} + {status === 'success' && ( +

{profile?.bio}

+ )} +
+

Reputation Score

-
- {[ - { label: 'Total Gigs', value: '0' }, - { label: 'Completed', value: '0%' }, - { label: 'Rating', value: '—' }, - ].map((stat) => ( -
-
{stat.value}
-
{stat.label}
-
- ))} -
+ {status === 'loading' ? ( +

Loading reputation...

+ ) : ( +
+ {[ + { label: 'Score', value: profile ? `${profile.reputation.score}` : '0' }, + { + label: 'Total Gigs', + value: profile ? `${profile.reputation.totalGigs}` : '0', + }, + { + label: 'Completion', + value: profile ? `${profile.reputation.completionRate}%` : '0%', + }, + { + label: 'Avg Rating', + value: profile ? profile.reputation.averageRating.toFixed(1) : '0.0', + }, + ].map(stat => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ )}
-

Work History

-
-
📋
-

No completed gigs yet

-
+

Past Gigs

+ {status === 'loading' && ( +

Loading past gigs...

+ )} + {status === 'success' && profile && profile.pastGigs.length === 0 && ( +
+
📋
+

No completed gigs yet

+
+ )} + {status === 'success' && profile && profile.pastGigs.length > 0 && ( +
+ {profile.pastGigs.map(gig => ( +
+
+
+

{gig.title}

+

+ {gig.client} • Completed {formattedDate(gig.completedAt)} +

+
+
+
+ {gig.payoutUSDC.toLocaleString()} USDC +
+
+ ★ {gig.rating.toFixed(1)} +
+
+
+
+ ))} +
+ )}