diff --git a/app/earn/page.tsx b/app/earn/page.tsx index 24a472708..2aec26d5c 100644 --- a/app/earn/page.tsx +++ b/app/earn/page.tsx @@ -6,13 +6,10 @@ import { FeedContent } from '@/components/Feed/FeedContent'; import { BountyService } from '@/services/bounty.service'; import { FeedEntry } from '@/types/feed'; import { EarnRightSidebar } from '@/components/Earn/EarnRightSidebar'; -import { Coins } from 'lucide-react'; import { MainPageHeader } from '@/components/ui/MainPageHeader'; import Icon from '@/components/ui/icons/Icon'; -import { BountyHubSelector as HubsSelector, Hub } from '@/components/Earn/BountyHubSelector'; +import { HubsSelector, HubsSelected, Hub } from '@/components/Hub/HubSelector'; import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; -import { Badge } from '@/components/ui/Badge'; -import { X } from 'lucide-react'; import { useClickContext } from '@/contexts/ClickContext'; export default function EarnPage() { @@ -122,12 +119,7 @@ export default function EarnPage() { {/* Top filter bar */}
- +
- {/* Selected hubs badges */} {selectedHubs.length > 0 && ( -
- {selectedHubs.map((hub) => ( - - Topic: {hub.name} - - - ))} -
+ )}
); diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index f0b8e691e..3917f24e6 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -8,61 +8,128 @@ import { GrantRightSidebar } from '@/components/Fund/GrantRightSidebar'; import { MainPageHeader } from '@/components/ui/MainPageHeader'; import { MarketplaceTabs, MarketplaceTab } from '@/components/Fund/MarketplaceTabs'; import Icon from '@/components/ui/icons/Icon'; +import { useState, useEffect } from 'react'; +import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; +import { HubsSelector, HubsSelected, Hub } from '@/components/Hub/HubSelector'; + +const SORT_OPTIONS_MAP: Record = { + grants: [ + { value: 'grants__amount', label: 'Amount' }, + { value: 'newest', label: 'Created date' }, + { value: 'end_date', label: 'Expiring soon' }, + { value: 'application_count', label: 'Most applications' }, + ], + 'needs-funding': [ + { value: 'newest', label: 'Created date' }, + { value: 'hot_score', label: 'Popular' }, + { value: 'upvotes', label: 'Most upvoted' }, + { value: 'amount_raised', label: 'Amount raised' }, + { value: 'goal_amount', label: 'Goal' }, + { value: 'end_date', label: 'Expiring soon' }, + { value: 'review_count', label: 'Most reviews' }, + ], + 'previously-funded': [ + { value: 'goal_amount', label: 'Goal' }, + { value: 'amount_raised', label: 'Amount raised' }, + { value: 'newest', label: 'Created date' }, + ], +}; + +const DEFAULT_SORT_MAP: Record = { + grants: 'end_date', + 'needs-funding': 'end_date', + 'previously-funded': 'newest', +}; + +// Needs replaced with getPageInfo from layouts/TopBar.tsx +const PAGE_TITLE_MAP: Record = { + grants: 'Request for Proposals', + 'needs-funding': 'Proposals', + 'previously-funded': 'Previously Funded', +}; + +// Needs replaced with getPageInfo from layouts/TopBar.tsx +const PAGE_SUBTITLE_MAP: Record = { + grants: 'Explore available funding opportunities', + 'needs-funding': 'Fund breakthrough research shaping tomorrow', + 'previously-funded': 'Browse research that has been successfully funded', +}; + +const HUB_TYPE_MAP: Record = { + grants: 'grant', + 'needs-funding': 'needs-funding', + 'previously-funded': 'bounty', +}; interface FundPageContentProps { marketplaceTab: MarketplaceTab; } export function FundPageContent({ marketplaceTab }: FundPageContentProps) { + const [sort, setSort] = useState(DEFAULT_SORT_MAP[marketplaceTab]); + const [selectedHubs, setSelectedHubs] = useState([]); + const [managedEntries, setManagedEntries] = useState([]); + const getFundraiseStatus = (tab: MarketplaceTab): 'OPEN' | 'CLOSED' | undefined => { - if (tab === 'needs-funding') return 'OPEN'; + if (tab === 'needs-funding' || tab === 'grants') return 'OPEN'; if (tab === 'previously-funded') return 'CLOSED'; return undefined; }; - const getOrdering = (tab: MarketplaceTab): string | undefined => { - if (tab === 'needs-funding') return 'amount_raised'; - return undefined; - }; - - const { entries, isLoading, hasMore, loadMore } = useFeed('all', { + const { entries, isLoading, hasMore, loadMore, refresh } = useFeed('all', { contentType: marketplaceTab === 'grants' ? 'GRANT' : 'PREREGISTRATION', endpoint: marketplaceTab === 'grants' ? 'grant_feed' : 'funding_feed', fundraiseStatus: getFundraiseStatus(marketplaceTab), - ordering: getOrdering(marketplaceTab), + ordering: sort, + hubIds: selectedHubs.map((h) => h.id), }); - const getTitle = (tab: MarketplaceTab): string => { - switch (tab) { - case 'grants': - return 'Request for Proposals'; - case 'needs-funding': - return 'Proposals'; - case 'previously-funded': - return 'Previously Funded'; - default: - return ''; - } - }; + // Manage the entries separate from hook to allow for clearing the feed when filter and sort options change. + useEffect(() => { + setManagedEntries(entries); + }, [entries]); - const getSubtitle = (tab: MarketplaceTab): string => { - switch (tab) { - case 'grants': - return 'Explore available funding opportunities'; - case 'needs-funding': - return 'Fund breakthrough research shaping tomorrow'; - case 'previously-funded': - return 'Browse research that has been successfully funded'; - default: - return ''; - } + useEffect(() => { + setManagedEntries([]); + refresh(); + }, [sort, selectedHubs]); + + const handleHubsChange = (hubs: any[]) => { + setSelectedHubs(hubs); }; + const renderFilters = () => ( +
+ {/* Top filter bar */} +
+
+ +
+
+ setSort(opt.value)} + options={SORT_OPTIONS_MAP[marketplaceTab]} + /> +
+
+ + {selectedHubs.length > 0 && ( + + )} +
+ ); + + // Special headers for mobile. Needs resolved with TopBar const header = ( } - title={getTitle(marketplaceTab)} - subtitle={getSubtitle(marketplaceTab)} + title={PAGE_TITLE_MAP[marketplaceTab]} + subtitle={PAGE_SUBTITLE_MAP[marketplaceTab]} /> ); @@ -72,12 +139,14 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { {header} {}} /> + ); diff --git a/app/layouts/TopBar.tsx b/app/layouts/TopBar.tsx index 03459f501..c99c8d8e4 100644 --- a/app/layouts/TopBar.tsx +++ b/app/layouts/TopBar.tsx @@ -47,6 +47,7 @@ const isRootNavigationPage = (pathname: string): boolean => { '/earn', '/fund/grants', '/fund/needs-funding', // Fundraises page + '/fund/previously-funded', '/journal', '/notebook', '/leaderboard', diff --git a/components/Earn/BountyHubSelector.tsx b/components/Hub/HubSelector.tsx similarity index 55% rename from components/Earn/BountyHubSelector.tsx rename to components/Hub/HubSelector.tsx index 5dcd87ead..f097b15c4 100644 --- a/components/Earn/BountyHubSelector.tsx +++ b/components/Hub/HubSelector.tsx @@ -1,15 +1,13 @@ -'use client'; - import { useEffect, useState, useCallback, useRef } from 'react'; import { MultiSelectOption, SearchableMultiSelect, } from '@/components/ui/form/SearchableMultiSelect'; import { Badge } from '@/components/ui/Badge'; -import { Button } from '@/components/ui/Button'; import { X, ChevronDown, Filter } from 'lucide-react'; import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { BountyService } from '@/services/bounty.service'; +import { FeedService } from '@/services/feed.service'; import { Topic } from '@/types/topic'; export interface Hub { @@ -19,21 +17,19 @@ export interface Hub { color?: string; } -interface BountyHubSelectorProps { +interface HubsSelectorProps { selectedHubs: Hub[]; onChange: (hubs: Hub[]) => void; error?: string | null; - displayCountOnly?: boolean; - hideSelectedItems?: boolean; + hubType?: 'grant' | 'needs-funding' | 'bounty'; } -export function BountyHubSelector({ +export function HubsSelector({ selectedHubs, onChange, error, - displayCountOnly = false, - hideSelectedItems = false, -}: BountyHubSelectorProps) { + hubType, +}: Readonly) { const [allHubs, setAllHubs] = useState([]); const [menuOpen, setMenuOpen] = useState(false); const menuContentRef = useRef(null); @@ -64,7 +60,15 @@ export function BountyHubSelector({ // fetch all hubs at mount useEffect(() => { (async () => { - const hubs = await BountyService.getBountyHubs(); + let hubs; + if (hubType === 'grant') { + hubs = await FeedService.getFeedHubs('grant_feed'); + setAllHubs(hubs); + } else if (hubType === 'needs-funding') { + hubs = await FeedService.getFeedHubs('funding_feed'); + } else { + hubs = await BountyService.getBountyHubs(); + } setAllHubs(hubs); })(); }, []); @@ -91,7 +95,7 @@ export function BountyHubSelector({ const allHubOptions = hubsToOptions(topicsToHubs(allHubs)); - // Local search within allHubs + // Local search within all Hubs const filterHubs = useCallback( async (query: string): Promise => { if (!query) { @@ -106,93 +110,71 @@ export function BountyHubSelector({ const handleChange = (options: MultiSelectOption[]) => { onChange(optionsToHubs(options)); - if (displayCountOnly) { - setMenuOpen(false); - } + setMenuOpen(false); }; - const CustomSelectedItems = () => ( -
- {selectedHubs.map((hub) => ( - { - e.preventDefault(); - e.stopPropagation(); - onChange(selectedHubs.filter((h) => h.id !== hub.id)); - if (displayCountOnly) { - setMenuOpen(false); - } - }} - > - {hub.color && ( -
- )} - {hub.name} - - ))} -
+ const trigger = ( + ); - if (displayCountOnly) { - const trigger = ( - - ); - - return ( - -
- -
-
- ); - } - return ( -
-
+ +
- {selectedHubs.length > 0 && !hideSelectedItems && }
+
+ ); +} + +export function HubsSelected({ + selectedHubs, + onChange, +}: Readonly<{ + selectedHubs: Hub[]; + onChange: (hubs: Hub[]) => void; +}>) { + return ( +
+ {selectedHubs.map((hub) => ( + + Topic: {hub.name} + + + ))}
); } diff --git a/components/ui/SortDropdown.tsx b/components/ui/SortDropdown.tsx index 63887d94b..7bac78bef 100644 --- a/components/ui/SortDropdown.tsx +++ b/components/ui/SortDropdown.tsx @@ -1,5 +1,5 @@ import { FC, useState } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, ArrowDownUp } from 'lucide-react'; import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; export interface SortOption { @@ -34,8 +34,9 @@ export const SortDropdown: FC = ({ type="button" className={`flex w-full items-center gap-2 border border-gray-200 bg-gray-50 hover:bg-gray-100 rounded-lg px-3 py-1.5 text-sm min-w-[120px] justify-between ${className}`} > - {activeOption.label} - + + {activeOption.label} + ); diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts index 19c3fc8b5..2d8c054c6 100644 --- a/hooks/useFeed.ts +++ b/hooks/useFeed.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { FeedEntry } from '@/types/feed'; import { FeedService } from '@/services/feed.service'; import { useSession } from 'next-auth/react'; +import { isEqual, omit } from 'lodash'; export type FeedTab = 'following' | 'latest' | 'popular'; export type FundingTab = 'all' | 'open' | 'closed'; @@ -19,6 +20,7 @@ interface UseFeedOptions { entries: FeedEntry[]; hasMore: boolean; }; + hubIds?: (string | number)[]; // Hub id's to filter by } export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions = {}) => { @@ -30,6 +32,18 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions const [currentTab, setCurrentTab] = useState(activeTab); const [currentOptions, setCurrentOptions] = useState(options); + // Re-load the feed if any of the relevant options change + const omitCheckKeys = ['initialData']; // Keys to ignore when comparing options + useEffect(() => { + const filteredOptions = omit(options, omitCheckKeys); + const filteredCurrentOptions = omit(currentOptions, omitCheckKeys); + + if (!isEqual(filteredOptions, filteredCurrentOptions)) { + setCurrentOptions(options); + loadFeed(); + } + }, [options]); + // Only load the feed when the component mounts or when the session status changes // We no longer reload when activeTab changes, as that will be handled by page navigation useEffect(() => { @@ -56,57 +70,16 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions } }, [status, activeTab]); - // Check if options have changed - useEffect(() => { - // Compare relevant options (excluding initialData which shouldn't trigger a reload) - const relevantOptionsChanged = - options.hubSlug !== currentOptions.hubSlug || - options.contentType !== currentOptions.contentType || - options.source !== currentOptions.source || - options.endpoint !== currentOptions.endpoint || - options.fundraiseStatus !== currentOptions.fundraiseStatus || - options.createdBy !== currentOptions.createdBy || - options.ordering !== currentOptions.ordering; - - if (relevantOptionsChanged) { - setCurrentOptions(options); - loadFeed(); - } - }, [options]); - - const loadFeed = async () => { - setIsLoading(true); - try { - const result = await FeedService.getFeed({ - page: 1, - pageSize: 20, - feedView: activeTab as FeedTab, // Only pass feedView if it's a FeedTab - hubSlug: options.hubSlug, - contentType: options.contentType, - source: options.source, - endpoint: options.endpoint, - fundraiseStatus: options.fundraiseStatus, - createdBy: options.createdBy, - ordering: options.ordering, - }); - setEntries(result.entries); - setHasMore(result.hasMore); - setPage(1); - } catch (error) { - console.error('Error loading feed:', error); - } finally { - setIsLoading(false); + // Load feed items for first or subsequent pages. + const loadFeed = async (pageNumber: number = 1) => { + if (pageNumber > 1 && (!hasMore || isLoading)) { + return; } - }; - - const loadMore = async () => { - if (!hasMore || isLoading) return; setIsLoading(true); try { - const nextPage = page + 1; const result = await FeedService.getFeed({ - page: nextPage, + page: pageNumber, pageSize: 20, feedView: activeTab as FeedTab, // Only pass feedView if it's a FeedTab hubSlug: options.hubSlug, @@ -116,12 +89,17 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions fundraiseStatus: options.fundraiseStatus, createdBy: options.createdBy, ordering: options.ordering, + hubIds: options.hubIds, }); - setEntries((prev) => [...prev, ...result.entries]); + if (pageNumber === 1) { + setEntries(result.entries); + } else { + setEntries((prev) => [...prev, ...result.entries]); + } setHasMore(result.hasMore); - setPage(nextPage); + setPage(pageNumber); } catch (error) { - console.error('Error loading more feed items:', error); + console.error('Error loading feed for page:', pageNumber, error); } finally { setIsLoading(false); } @@ -131,7 +109,7 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions entries, isLoading, hasMore, - loadMore, + loadMore: () => loadFeed(page + 1), refresh: loadFeed, }; }; diff --git a/services/feed.service.ts b/services/feed.service.ts index f58822b26..092d2e778 100644 --- a/services/feed.service.ts +++ b/services/feed.service.ts @@ -4,12 +4,27 @@ import { Bounty, BountyType, transformBounty } from '@/types/bounty'; import { transformUser, User } from '@/types/user'; import { transformAuthorProfile } from '@/types/authorProfile'; import { Fundraise, transformFundraise } from '@/types/funding'; +import { Topic, transformTopic } from '@/types/topic'; + +type Endpoints = 'feed' | 'funding_feed' | 'grant_feed' | undefined; export class FeedService { private static readonly BASE_PATH = '/api/feed'; private static readonly FUNDING_PATH = '/api/funding_feed'; private static readonly GRANT_PATH = '/api/grant_feed'; + // Determine which endpoint to use + private static getEndpointPath(endpoint: Endpoints) { + switch (endpoint) { + case 'funding_feed': + return this.FUNDING_PATH; + case 'grant_feed': + return this.GRANT_PATH; + default: + return this.BASE_PATH; + } + } + static async getFeed(params?: { page?: number; pageSize?: number; @@ -17,11 +32,12 @@ export class FeedService { hubSlug?: string; contentType?: string; source?: 'all' | 'researchhub'; - endpoint?: 'feed' | 'funding_feed' | 'grant_feed'; + endpoint?: Endpoints; fundraiseStatus?: 'OPEN' | 'CLOSED'; grantId?: number; createdBy?: number; ordering?: string; + hubIds?: (string | number)[]; }): Promise<{ entries: FeedEntry[]; hasMore: boolean }> { const queryParams = new URLSearchParams(); if (params?.page) queryParams.append('page', params.page.toString()); @@ -34,14 +50,11 @@ export class FeedService { if (params?.grantId) queryParams.append('grant_id', params.grantId.toString()); if (params?.createdBy) queryParams.append('created_by', params.createdBy.toString()); if (params?.ordering) queryParams.append('ordering', params.ordering); + if (params?.hubIds && params.hubIds.length > 0) { + queryParams.append('hub_ids', JSON.stringify(params.hubIds)); + } - // Determine which endpoint to use - const basePath = - params?.endpoint === 'funding_feed' - ? this.FUNDING_PATH - : params?.endpoint === 'grant_feed' - ? this.GRANT_PATH - : this.BASE_PATH; + const basePath = this.getEndpointPath(params?.endpoint); const url = `${basePath}/${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; try { @@ -243,4 +256,22 @@ export class FeedService { return transformFundraise(formattedRawFundraise); } + + static async getFeedHubs(endpoint: Endpoints): Promise { + // Hub search not implemented for feed + if (endpoint === 'feed') { + return []; + } + + let basePath = this.getEndpointPath(endpoint); + const path = `${basePath}/hubs/`; + + try { + const response = await ApiClient.get(path); + return response.map((raw) => transformTopic(raw)); + } catch (error) { + console.error(`Error fetching ${endpoint} hubs`, error); + return []; + } + } }