diff --git a/hooks/index.ts b/hooks/index.ts index b24803c..a6557b4 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -1,4 +1,6 @@ export * from "./useAccount"; +export * from "./useDebouncedValue"; +export * from "./useGigsExplorer"; export * from "./useWallet"; export * from "./useIsMounted"; export * from "./useSubscription"; diff --git a/hooks/useDebouncedValue.ts b/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..f26ae20 --- /dev/null +++ b/hooks/useDebouncedValue.ts @@ -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(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs) + return () => clearTimeout(timer) + }, [value, delayMs]) + + return debounced +} diff --git a/hooks/useGigsExplorer.ts b/hooks/useGigsExplorer.ts new file mode 100644 index 0000000..bc2c32d --- /dev/null +++ b/hooks/useGigsExplorer.ts @@ -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([]) + const [cursor, setCursor] = useState(null) + const [hasMore, setHasMore] = useState(true) + const [total, setTotal] = useState(0) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(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 } +} diff --git a/package-lock.json b/package-lock.json index 6300965..d73586a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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", @@ -1821,6 +1822,33 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.7.tgz", + "integrity": "sha512-11uSrj77IDijNBqizD4lY4y1laMyRrqMLSxjnWy5CvWkCjyRDW+gGmxYq0lwQKVas/sq7zyzYWXbL/BvBzR32g==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.5.tgz", + "integrity": "sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/humanize-duration": { "version": "3.27.1", "resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.1.tgz", diff --git a/package.json b/package.json index 8c10d07..6abcfa2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/api/gigs.ts b/pages/api/gigs.ts new file mode 100644 index 0000000..3d6773c --- /dev/null +++ b/pages/api/gigs.ts @@ -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 { + 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 + return NextResponse.json({ ...payload, source: 'backend' }, { headers: cacheHeaders }) + } catch { + return NextResponse.json(getMockGigsResponse(searchParams), { headers: cacheHeaders }) + } +} diff --git a/pages/explore.tsx b/pages/explore.tsx index 1903bbb..b13df5d 100644 --- a/pages/explore.tsx +++ b/pages/explore.tsx @@ -1,72 +1,111 @@ -import { useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { NextPage } from 'next' import Head from 'next/head' import Link from 'next/link' +import { useRouter } from 'next/router' +import { useVirtualizer } from '@tanstack/react-virtual' import { Navbar } from '../components/organisms' +import { useDebouncedValue, useGigsExplorer } from '../hooks' +import type { GigSort } from './api/gigs' -interface Gig { - id: string - title: string - description: string - budget: string - milestones: number - category: string - duration: string - poster: string - postedDate: string -} +const CATEGORIES = ['All', 'Development', 'Design', 'Writing', 'Marketing', 'Other'] -const MOCK_GIGS: Gig[] = [ - { - id: '1', - title: 'Smart Contract Audit for DeFi Protocol', - description: 'Need experienced Solidity auditor to review our lending protocol smart contracts for security vulnerabilities.', - budget: '5000 XLM', - milestones: 3, - category: 'Development', - duration: '2 weeks', - poster: 'GABC...XYZ', - postedDate: '2 days ago', - }, - { - id: '2', - title: 'UI/UX Design for NFT Marketplace', - description: 'Looking for a talented designer to create modern, user-friendly interface for our NFT marketplace on Stellar.', - budget: '3000 XLM', - milestones: 4, - category: 'Design', - duration: '3 weeks', - poster: 'GDEF...ABC', - postedDate: '5 days ago', - }, - { - id: '3', - title: 'Content Writing for Blockchain Blog', - description: 'Seeking technical writer to produce 10 high-quality articles about Stellar blockchain and Soroban smart contracts.', - budget: '1500 XLM', - milestones: 10, - category: 'Writing', - duration: '1 month', - poster: 'GHIJ...DEF', - postedDate: '1 week ago', - }, +const SORT_OPTIONS: { label: string; value: GigSort }[] = [ + { label: 'Latest', value: 'latest' }, + { label: 'Budget: High to Low', value: 'budget_desc' }, + { label: 'Budget: Low to High', value: 'budget_asc' }, + { label: 'Deadline', value: 'deadline' }, ] -const CATEGORIES = ['All', 'Development', 'Design', 'Writing', 'Marketing', 'Other'] -const SORT_OPTIONS = ['Latest', 'Budget: High to Low', 'Budget: Low to High', 'Deadline'] +const LIST_HEIGHT_PX = 720 +const ROW_HEIGHT_PX = 190 +const LOAD_MORE_THRESHOLD = 4 + +function isGigSort(value: unknown): value is GigSort { + return value === 'latest' || value === 'budget_desc' || value === 'budget_asc' || value === 'deadline' +} + +function readQueryParam(value: string | string[] | undefined): string { + if (Array.isArray(value)) return value[0] ?? '' + return value ?? '' +} const Explore: NextPage = () => { - const [selectedCategory, setSelectedCategory] = useState('All') - const [sortBy, setSortBy] = useState('Latest') - const [searchQuery, setSearchQuery] = useState('') - - const filteredGigs = MOCK_GIGS.filter((gig) => { - const matchesCategory = selectedCategory === 'All' || gig.category === selectedCategory - const matchesSearch = gig.title.toLowerCase().includes(searchQuery.toLowerCase()) || - gig.description.toLowerCase().includes(searchQuery.toLowerCase()) - return matchesCategory && matchesSearch + const router = useRouter() + + // Filters are only meaningful once we've read the initial values out of + // the URL (router.query is empty on the very first render). Everything + // downstream waits on `hydrated` so we never briefly show/fetch the + // defaults and then jump to the real URL-driven state. + const [hydrated, setHydrated] = useState(false) + const [category, setCategory] = useState('All') + const [sort, setSort] = useState('latest') + const [searchInput, setSearchInput] = useState('') + + useEffect(() => { + if (!router.isReady || hydrated) return + + const queryCategory = readQueryParam(router.query.category) + const querySort = readQueryParam(router.query.sort) + const querySearch = readQueryParam(router.query.q) + + if (CATEGORIES.includes(queryCategory)) setCategory(queryCategory) + if (isGigSort(querySort)) setSort(querySort) + if (querySearch) setSearchInput(querySearch) + + setHydrated(true) + // Only run this once, right after the router has the real query. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, hydrated]) + + const debouncedSearch = useDebouncedValue(searchInput, 300) + + // Keep the URL in sync with filter state so refresh and back/forward + // restore exactly what the user had selected. Shallow + replace: this is + // filter state, not new pages, so it shouldn't spam browser history. + useEffect(() => { + if (!hydrated) return + + const query: Record = {} + if (category !== 'All') query.category = category + if (sort !== 'latest') query.sort = sort + if (debouncedSearch.trim()) query.q = debouncedSearch.trim() + + router.replace({ pathname: '/explore', query }, undefined, { shallow: true }) + // router is stable across renders in Next's pages router; omitting it + // avoids re-running this on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated, category, sort, debouncedSearch]) + + const filters = useMemo( + () => ({ category, search: debouncedSearch, sort }), + [category, debouncedSearch, sort], + ) + + const { items, status, error, hasMore, total, fetchNextPage } = useGigsExplorer(filters) + + const scrollParentRef = useRef(null) + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollParentRef.current, + estimateSize: () => ROW_HEIGHT_PX, + overscan: 6, }) + const virtualItems = virtualizer.getVirtualItems() + + // Infinite scroll: once the windowed list has rendered close to the end + // of what we've loaded, ask for the next cursor page. + useEffect(() => { + const lastItem = virtualItems[virtualItems.length - 1] + if (!lastItem) return + if (lastItem.index >= items.length - LOAD_MORE_THRESHOLD && hasMore && status !== 'loading' && status !== 'loadingMore') { + fetchNextPage() + } + }, [virtualItems, items.length, hasMore, status, fetchNextPage]) + + const isInitialLoading = status === 'loading' && items.length === 0 + return ( <> @@ -95,8 +134,8 @@ const Explore: NextPage = () => { setSearchQuery(e.target.value)} + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} className="w-full px-4 py-3 pl-12 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500" /> {
{/* Categories */}
- {CATEGORIES.map((category) => ( + {CATEGORIES.map((c) => ( ))}
{/* Sort dropdown */} @@ -148,65 +187,16 @@ const Explore: NextPage = () => { {/* Results count */}
- {filteredGigs.length} {filteredGigs.length === 1 ? 'gig' : 'gigs'} found + {isInitialLoading ? 'Loading gigs…' : `${total} ${total === 1 ? 'gig' : 'gigs'} found`}
- {/* Gigs grid */} - {filteredGigs.length > 0 ? ( -
- {filteredGigs.map((gig) => ( -
- {/* Category badge */} -
- - {gig.category} - - - {gig.postedDate} - -
- - {/* Title */} -

- {gig.title} -

- - {/* Description */} -

- {gig.description} -

- - {/* Budget and details */} -
-
-
Budget
-
{gig.budget}
-
-
-
Duration
-
{gig.duration}
-
-
- - {/* Footer */} -
-
- {gig.milestones} milestones -
- - View Details - -
-
- ))} + {status === 'error' && ( +
+ {error}
- ) : ( + )} + + {!isInitialLoading && items.length === 0 ? ( /* Empty state */
🔍
@@ -216,34 +206,103 @@ const Explore: NextPage = () => {

- )} + ) : ( + /* Virtualized, infinite-scrolling gig list */ +
+
+ {virtualItems.map((virtualRow) => { + const gig = items[virtualRow.index] + if (!gig) return null - {/* Pagination */} - {filteredGigs.length > 0 && ( -
- - - - - + return ( +
+
+
+ {/* Category badge */} +
+ + {gig.category} + + + {new Date(gig.postedAt).toLocaleDateString()} + +
+ + {/* Title */} +

+ {gig.title} +

+ + {/* Description */} +

+ {gig.description} +

+
+ + {/* Footer */} +
+
+
+
Budget
+
{gig.budgetXLM.toLocaleString()} XLM
+
+
+
Duration
+
{gig.durationDays} days
+
+
+
Milestones
+
{gig.milestones}
+
+
+ + View Details + +
+
+
+ ) + })} +
+ + {status === 'loadingMore' && ( +
+ Loading more gigs… +
+ )}
)}