Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
1 change: 1 addition & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./useAccount";
export * from "./useIsMounted";
export * from './useSubscription';
export * from './useUSDCPrice';
export * from './useUserProfile';
56 changes: 56 additions & 0 deletions hooks/useUserProfile.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

export function useUserProfile(walletAddress?: string): UseUserProfileResult {
const [profile, setProfile] = useState<UserProfileResponse | null>(null)
const [status, setStatus] = useState<ProfileStatus>('idle')
const [error, setError] = useState<string | null>(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,
}
}
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dashboard": "Dashboard",
"escrow": "Escrow",
"launchApp": "Launch App",
"connectWallet": "Connect Wallet",
"openMenu": "Open menu",
"closeMenu": "Close menu"
},
Expand Down
1 change: 1 addition & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dashboard": "Panel",
"escrow": "Depósito en garantía",
"launchApp": "Abrir app",
"connectWallet": "Conectar billetera",
"openMenu": "Abrir menú",
"closeMenu": "Cerrar menú"
},
Expand Down
155 changes: 155 additions & 0 deletions pages/api/profile.ts
Original file line number Diff line number Diff line change
@@ -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<UserProfileResponse | UserProfileError>
) {
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))
}
}
Loading
Loading