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
2 changes: 2 additions & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from "./useAccount";
export * from "./useDebouncedValue";
export * from "./useGigsExplorer";
export * from "./useWallet";
export * from "./useIsMounted";
export * from "./useSubscription";
Expand Down
16 changes: 16 additions & 0 deletions hooks/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react'

/**
* Returns `value`, but delayed by `delayMs` after the last change.
* Used to avoid firing a request (or a URL update) on every keystroke.
*/
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value)

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(timer)
}, [value, delayMs])

return debounced
}
112 changes: 112 additions & 0 deletions hooks/useGigsExplorer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Gig, GigSort } from '../pages/api/gigs'

type GigsStatus = 'idle' | 'loading' | 'loadingMore' | 'success' | 'error'

export interface GigsFilters {
category: string
search: string
sort: GigSort
}

interface UseGigsExplorerResult {
items: Gig[]
status: GigsStatus
error: string | null
hasMore: boolean
total: number
fetchNextPage: () => void
}

function buildQuery(filters: GigsFilters, cursor: string | null): string {
const params = new URLSearchParams()
if (filters.category && filters.category !== 'All') {
params.set('category', filters.category)
}
if (filters.search.trim()) {
params.set('search', filters.search.trim())
}
params.set('sort', filters.sort)
if (cursor) {
params.set('cursor', cursor)
}
return params.toString()
}

/**
* Cursor-based, server-driven gig listing.
*
* Filters are owned by the caller (typically synced to the URL) and passed
* in as a stable-ish object; changing any of them resets pagination and
* refetches from the first page. `fetchNextPage` appends the next cursor's
* worth of results for infinite scroll.
*/
export function useGigsExplorer(filters: GigsFilters): UseGigsExplorerResult {
const [items, setItems] = useState<Gig[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const [total, setTotal] = useState(0)
const [status, setStatus] = useState<GigsStatus>('idle')
const [error, setError] = useState<string | null>(null)

// Guards against out-of-order responses when filters change quickly
// (e.g. fast typing in search) by ignoring stale requests.
const requestIdRef = useRef(0)

const loadPage = useCallback(
async (targetCursor: string | null, mode: 'replace' | 'append') => {
const requestId = ++requestIdRef.current
setStatus(mode === 'replace' ? 'loading' : 'loadingMore')
setError(null)

try {
const query = buildQuery(filters, targetCursor)
const response = await fetch(`/api/gigs?${query}`)
if (!response.ok) {
throw new Error(`Failed to load gigs: ${response.status}`)
}
const payload = (await response.json()) as {
items: Gig[]
nextCursor: string | null
total: number
}

if (requestId !== requestIdRef.current) {
// A newer request has already started; drop this stale response.
return
}

setItems((prev) => (mode === 'replace' ? payload.items : [...prev, ...payload.items]))
setCursor(payload.nextCursor)
setHasMore(payload.nextCursor !== null)
setTotal(payload.total)
setStatus('success')
} catch {
if (requestId !== requestIdRef.current) return
setStatus('error')
setError('Unable to load gigs right now. Please try again.')
}
},
// filters is intentionally the trigger for a full reset; see the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
[filters.category, filters.search, filters.sort],
)

useEffect(() => {
setItems([])
setCursor(null)
setHasMore(true)
loadPage(null, 'replace')
// Re-run whenever the filters meaningfully change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.category, filters.search, filters.sort])

const fetchNextPage = useCallback(() => {
if (status === 'loading' || status === 'loadingMore' || !hasMore) {
return
}
loadPage(cursor, 'append')
}, [cursor, hasMore, loadPage, status])

return { items, status, error, hasMore, total, fetchNextPage }
}
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "1.0.2",
"@stellar/freighter-api": "^1.5.1",
"@tanstack/react-virtual": "^3.14.7",
"axios": "^0.27.2",
"bigint-conversion": "^2.4.1",
"humanize-duration": "^3.27.3",
Expand Down
190 changes: 190 additions & 0 deletions pages/api/gigs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export const runtime = 'edge'

export interface Gig {
id: string
title: string
description: string
budgetXLM: number
milestones: number
category: string
durationDays: number
poster: string
postedAt: string
}

export interface GigsResponse {
items: Gig[]
nextCursor: string | null
total: number
source: 'backend' | 'mock'
}

export type GigSort = 'latest' | 'budget_desc' | 'budget_asc' | 'deadline'

const CATEGORIES = ['Development', 'Design', 'Writing', 'Marketing', 'Other']
const DEFAULT_LIMIT = 20
const MAX_LIMIT = 50
const MOCK_TOTAL = 240

// Deterministic pseudo-random generator so the mock dataset (and therefore
// cursor offsets) stay stable across requests instead of reshuffling on
// every call to Math.random().
function mulberry32(seed: number) {
return function random() {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}

function buildMockDataset(): Gig[] {
const random = mulberry32(42)
const titles = [
'Smart Contract Audit for DeFi Protocol',
'UI/UX Design for NFT Marketplace',
'Content Writing for Blockchain Blog',
'Landing Page Copy for Wallet Launch',
'Soroban Contract Unit Test Suite',
'Marketing Campaign for Token Launch',
'Dashboard Redesign for Analytics Tool',
'Documentation Overhaul for SDK',
'Community Moderation for Discord',
'Video Explainer for Escrow Flow',
]

return Array.from({ length: MOCK_TOTAL }, (_, index) => {
const category = CATEGORIES[Math.floor(random() * CATEGORIES.length)]
const title = titles[Math.floor(random() * titles.length)]
const daysAgo = Math.floor(random() * 60)
const postedAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString()

return {
id: `gig-${index + 1}`,
title: `${title} #${index + 1}`,
description:
'Looking for an experienced contributor to deliver high-quality, milestone-based work on the TrustFlow protocol.',
budgetXLM: 500 + Math.floor(random() * 9500),
milestones: 1 + Math.floor(random() * 6),
category,
durationDays: 3 + Math.floor(random() * 40),
poster: `G${Math.random().toString(36).slice(2, 6).toUpperCase()}...${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
postedAt,
}
})
}

// Built once per module instance. Fine for mock data: it only needs to be
// stable for the lifetime of a single edge worker, not across deployments.
const MOCK_DATASET = buildMockDataset()

function applyFilters(items: Gig[], category: string | null, search: string | null): Gig[] {
let result = items

if (category && category !== 'All') {
result = result.filter((gig) => gig.category === category)
}

if (search) {
const query = search.trim().toLowerCase()
if (query) {
result = result.filter(
(gig) =>
gig.title.toLowerCase().includes(query) ||
gig.description.toLowerCase().includes(query),
)
}
}

return result
}

function applySort(items: Gig[], sort: GigSort): Gig[] {
const sorted = [...items]

switch (sort) {
case 'budget_desc':
return sorted.sort((a, b) => b.budgetXLM - a.budgetXLM)
case 'budget_asc':
return sorted.sort((a, b) => a.budgetXLM - b.budgetXLM)
case 'deadline':
return sorted.sort((a, b) => a.durationDays - b.durationDays)
case 'latest':
default:
return sorted.sort((a, b) => new Date(b.postedAt).getTime() - new Date(a.postedAt).getTime())
}
}

function parseCursor(cursor: string | null): number {
if (!cursor) return 0
const offset = Number.parseInt(cursor, 10)
return Number.isFinite(offset) && offset >= 0 ? offset : 0
}

function parseLimit(limit: string | null): number {
const parsed = limit ? Number.parseInt(limit, 10) : DEFAULT_LIMIT
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIMIT
return Math.min(parsed, MAX_LIMIT)
}

function isGigSort(value: string | null): value is GigSort {
return value === 'latest' || value === 'budget_desc' || value === 'budget_asc' || value === 'deadline'
}

function getMockGigsResponse(params: URLSearchParams): GigsResponse {
const category = params.get('category')
const search = params.get('search')
const sortParam = params.get('sort')
const sort: GigSort = isGigSort(sortParam) ? sortParam : 'latest'
const cursorOffset = parseCursor(params.get('cursor'))
const limit = parseLimit(params.get('limit'))

const filtered = applySort(applyFilters(MOCK_DATASET, category, search), sort)
const page = filtered.slice(cursorOffset, cursorOffset + limit)
const nextOffset = cursorOffset + page.length
const nextCursor = nextOffset < filtered.length ? String(nextOffset) : null

return {
items: page,
nextCursor,
total: filtered.length,
source: 'mock',
}
}

export default async function handler(req: NextRequest): Promise<NextResponse> {
if (req.method !== 'GET') {
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 })
}

const { searchParams } = new URL(req.url)
const cacheHeaders = { 'Cache-Control': 's-maxage=15, stale-while-revalidate=60' }

const backendBaseUrl = process.env.GIGS_API_BASE_URL
if (!backendBaseUrl) {
return NextResponse.json(getMockGigsResponse(searchParams), { headers: cacheHeaders })
}

try {
const url = new URL('/gigs', backendBaseUrl)
searchParams.forEach((value, key) => url.searchParams.set(key, value))

const response = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
})

if (!response.ok) {
return NextResponse.json(getMockGigsResponse(searchParams), { headers: cacheHeaders })
}

const payload = (await response.json()) as Omit<GigsResponse, 'source'>
return NextResponse.json({ ...payload, source: 'backend' }, { headers: cacheHeaders })
} catch {
return NextResponse.json(getMockGigsResponse(searchParams), { headers: cacheHeaders })
}
}
Loading
Loading