From 2bb9b1669dc117e7ec65548f31de6247ce0e7cbc Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:10:14 -0600 Subject: [PATCH 01/12] add sort and hub filter dropdowns --- app/earn/page.tsx | 3 +- app/fund/components/FundPageContent.tsx | 110 ++++++++++++++++-- .../HubSelector.tsx} | 19 ++- services/grant.service.ts | 18 +++ 4 files changed, 136 insertions(+), 14 deletions(-) rename components/{Earn/BountyHubSelector.tsx => Hub/HubSelector.tsx} (93%) diff --git a/app/earn/page.tsx b/app/earn/page.tsx index 24a472708..3450d9fc3 100644 --- a/app/earn/page.tsx +++ b/app/earn/page.tsx @@ -9,7 +9,7 @@ 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, Hub } from '@/components/Hub/HubSelector'; import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; import { Badge } from '@/components/ui/Badge'; import { X } from 'lucide-react'; @@ -127,6 +127,7 @@ export default function EarnPage() { onChange={handleHubsChange} displayCountOnly hideSelectedItems={true} + hubType="bounty" />
diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index f0b8e691e..5677b2cec 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -8,28 +8,32 @@ 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 } from 'react'; +import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; +import { Badge } from '@/components/ui/Badge'; +import { HubsSelector } from '@/components/Hub/HubSelector'; + +import { X, ChevronDown, Filter } from 'lucide-react'; interface FundPageContentProps { marketplaceTab: MarketplaceTab; } export function FundPageContent({ marketplaceTab }: FundPageContentProps) { + const [sort, setSort] = useState('-created_date'); + const [selectedHubs, setSelectedHubs] = 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', { contentType: marketplaceTab === 'grants' ? 'GRANT' : 'PREREGISTRATION', endpoint: marketplaceTab === 'grants' ? 'grant_feed' : 'funding_feed', fundraiseStatus: getFundraiseStatus(marketplaceTab), - ordering: getOrdering(marketplaceTab), + ordering: sort, }); const getTitle = (tab: MarketplaceTab): string => { @@ -58,6 +62,80 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { } }; + const getSortOptions = (tab: MarketplaceTab): SortOption[] => { + switch (tab) { + case 'grants': + return grantSortOptions; + case 'needs-funding': + return fundingProposalSortOption; + case 'previously-funded': + return previouslyFundedSortOptions; + } + }; + + const getHubType = (tab: MarketplaceTab): 'grant' | 'bounty' | undefined => { + switch (tab) { + case 'grants': + return 'grant'; + case 'needs-funding': + return 'bounty'; + case 'previously-funded': + return 'bounty'; + default: + return undefined; + } + }; + + const handleHubsChange = (hubs: any[]) => { + setSelectedHubs(hubs); + }; + + const renderFilters = () => ( +
+ {/* Top filter bar */} +
+
+ +
+
+ setSort(opt.value)} + options={getSortOptions(marketplaceTab)} + /> +
+
+ + {/* Selected hubs badges */} + {selectedHubs.length > 0 && ( +
+ {selectedHubs.map((hub) => ( + + Topic: {hub.name} + + + ))} +
+ )} +
+ ); + const header = ( } @@ -66,18 +144,36 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { /> ); + // Available sort options + const grantSortOptions = [ + { value: '-unified_document__grants__amount', label: 'Amount' }, + { value: '-created_date', label: 'Created Date' }, + { value: 'unified_document__grants__end_date', label: 'Expiring soon' }, + ]; + + const fundingProposalSortOption = [ + { value: '-unified_document__funding_proposals__amount', label: 'Amount Raised' }, + { value: 'test', label: 'Almost Funded' }, + { value: '-created_date', label: 'Created Date' }, + { value: 'unified_document__funding_proposals__end_date', label: 'Expiring soon' }, + ]; + + const previouslyFundedSortOptions = [{ value: '-created_date', label: 'Created Date' }]; + const rightSidebar = marketplaceTab === 'grants' ? : ; return ( {header} {}} /> + ); diff --git a/components/Earn/BountyHubSelector.tsx b/components/Hub/HubSelector.tsx similarity index 93% rename from components/Earn/BountyHubSelector.tsx rename to components/Hub/HubSelector.tsx index 5dcd87ead..f070f7cd6 100644 --- a/components/Earn/BountyHubSelector.tsx +++ b/components/Hub/HubSelector.tsx @@ -1,5 +1,3 @@ -'use client'; - import { useEffect, useState, useCallback, useRef } from 'react'; import { MultiSelectOption, @@ -10,6 +8,7 @@ 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 { GrantService } from '@/services/grant.service'; import { Topic } from '@/types/topic'; export interface Hub { @@ -19,21 +18,23 @@ export interface Hub { color?: string; } -interface BountyHubSelectorProps { +interface HubsSelectorProps { selectedHubs: Hub[]; onChange: (hubs: Hub[]) => void; error?: string | null; displayCountOnly?: boolean; hideSelectedItems?: boolean; + hubType?: 'grant' | 'bounty'; } -export function BountyHubSelector({ +export function HubsSelector({ selectedHubs, onChange, error, displayCountOnly = false, hideSelectedItems = false, -}: BountyHubSelectorProps) { + hubType, +}: HubsSelectorProps) { const [allHubs, setAllHubs] = useState([]); const [menuOpen, setMenuOpen] = useState(false); const menuContentRef = useRef(null); @@ -64,7 +65,13 @@ export function BountyHubSelector({ // fetch all hubs at mount useEffect(() => { (async () => { - const hubs = await BountyService.getBountyHubs(); + let hubs; + if (hubType === 'grant') { + hubs = await GrantService.getGrantHubs(); + setAllHubs(hubs); + } else { + hubs = await BountyService.getBountyHubs(); + } setAllHubs(hubs); })(); }, []); diff --git a/services/grant.service.ts b/services/grant.service.ts index 78a56e5ab..b830824da 100644 --- a/services/grant.service.ts +++ b/services/grant.service.ts @@ -1,4 +1,5 @@ import { ApiClient } from './client'; +import { Topic } from '@/types/topic'; export class GrantService { private static readonly BASE_PATH = '/api/grant'; @@ -28,4 +29,21 @@ export class GrantService { }); return response; } + + static async getGrantHubs(): Promise { + const path = `/api/grant_feed/hubs/`; + try { + const response = await ApiClient.get(path); + return response.map((raw) => ({ + id: raw.id, + name: raw.name || '', + slug: raw.slug || '', + description: raw.description, + imageUrl: raw.hub_image || undefined, + })); + } catch (error) { + console.error('Error fetching grant hubs:', error); + return []; + } + } } From e3a841776837764c060c32e6835900f8d9725a19 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Fri, 29 Aug 2025 16:47:30 -0600 Subject: [PATCH 02/12] move HubsSelected to Hubs selector component --- app/earn/page.tsx | 25 ++------------------- app/fund/components/FundPageContent.tsx | 29 +++++-------------------- components/Hub/HubSelector.tsx | 25 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 47 deletions(-) diff --git a/app/earn/page.tsx b/app/earn/page.tsx index 3450d9fc3..9448fbc13 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 { HubsSelector, Hub } from '@/components/Hub/HubSelector'; +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() { @@ -139,26 +136,8 @@ export default function EarnPage() {
- {/* 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 5677b2cec..dc695bf81 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -10,10 +10,9 @@ import { MarketplaceTabs, MarketplaceTab } from '@/components/Fund/MarketplaceTa import Icon from '@/components/ui/icons/Icon'; import { useState } from 'react'; import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; -import { Badge } from '@/components/ui/Badge'; -import { HubsSelector } from '@/components/Hub/HubSelector'; +import { HubsSelector, HubsSelected, Hub } from '@/components/Hub/HubSelector'; -import { X, ChevronDown, Filter } from 'lucide-react'; +import { X } from 'lucide-react'; interface FundPageContentProps { marketplaceTab: MarketplaceTab; @@ -21,7 +20,7 @@ interface FundPageContentProps { export function FundPageContent({ marketplaceTab }: FundPageContentProps) { const [sort, setSort] = useState('-created_date'); - const [selectedHubs, setSelectedHubs] = useState([]); + const [selectedHubs, setSelectedHubs] = useState([]); const getFundraiseStatus = (tab: MarketplaceTab): 'OPEN' | 'CLOSED' | undefined => { if (tab === 'needs-funding' || tab === 'grants') return 'OPEN'; @@ -91,7 +90,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { }; const renderFilters = () => ( -
+
{/* Top filter bar */}
@@ -112,26 +111,8 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) {
- {/* Selected hubs badges */} {selectedHubs.length > 0 && ( -
- {selectedHubs.map((hub) => ( - - Topic: {hub.name} - - - ))} -
+ )}
); diff --git a/components/Hub/HubSelector.tsx b/components/Hub/HubSelector.tsx index f070f7cd6..66524b8d0 100644 --- a/components/Hub/HubSelector.tsx +++ b/components/Hub/HubSelector.tsx @@ -203,3 +203,28 @@ export function HubsSelector({
); } + +export function HubsSelected({ + selectedHubs, + onChange, +}: { + selectedHubs: Hub[]; + onChange: (hubs: Hub[]) => void; +}) { + return ( +
+ {selectedHubs.map((hub) => ( + + Topic: {hub.name} + + + ))} +
+ ); +} From 43e8f9df2d92708b7acd2a93017a0cff5b38aee0 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Tue, 2 Sep 2025 08:24:28 -0600 Subject: [PATCH 03/12] add hub filtering --- app/fund/components/FundPageContent.tsx | 1 + hooks/useFeed.ts | 13 ++++++++++++- services/feed.service.ts | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index dc695bf81..2511e5e4d 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -33,6 +33,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { endpoint: marketplaceTab === 'grants' ? 'grant_feed' : 'funding_feed', fundraiseStatus: getFundraiseStatus(marketplaceTab), ordering: sort, + hubIds: selectedHubs.map((h) => h.id), }); const getTitle = (tab: MarketplaceTab): string => { diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts index 19c3fc8b5..4b1ef7574 100644 --- a/hooks/useFeed.ts +++ b/hooks/useFeed.ts @@ -19,6 +19,7 @@ interface UseFeedOptions { entries: FeedEntry[]; hasMore: boolean; }; + hubIds?: (string | number)[]; // Hub id's to filter by } export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions = {}) => { @@ -56,6 +57,13 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions } }, [status, activeTab]); + const arraysEqual = (a?: (string | number)[], b?: (string | number)[]) => { + if (a === b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + return a.every((val, i) => val === b[i]); + }; + // Check if options have changed useEffect(() => { // Compare relevant options (excluding initialData which shouldn't trigger a reload) @@ -66,7 +74,8 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions options.endpoint !== currentOptions.endpoint || options.fundraiseStatus !== currentOptions.fundraiseStatus || options.createdBy !== currentOptions.createdBy || - options.ordering !== currentOptions.ordering; + options.ordering !== currentOptions.ordering || + !arraysEqual(options.hubIds, currentOptions.hubIds); if (relevantOptionsChanged) { setCurrentOptions(options); @@ -88,6 +97,7 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions fundraiseStatus: options.fundraiseStatus, createdBy: options.createdBy, ordering: options.ordering, + hubIds: options.hubIds, }); setEntries(result.entries); setHasMore(result.hasMore); @@ -116,6 +126,7 @@ 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]); setHasMore(result.hasMore); diff --git a/services/feed.service.ts b/services/feed.service.ts index f58822b26..d038c8ae7 100644 --- a/services/feed.service.ts +++ b/services/feed.service.ts @@ -22,6 +22,7 @@ export class FeedService { 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,6 +35,9 @@ 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 = From 5329fd9a0ff187f3c7091b612134d75ef5120b89 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:23:07 -0600 Subject: [PATCH 04/12] add filters and sort to funded --- app/fund/components/FundPageContent.tsx | 17 ++++++--- components/Hub/HubSelector.tsx | 9 +++-- services/feed.service.ts | 50 +++++++++++++++++++++---- services/grant.service.ts | 17 --------- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index 2511e5e4d..46678c3f0 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -73,12 +73,12 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { } }; - const getHubType = (tab: MarketplaceTab): 'grant' | 'bounty' | undefined => { + const getHubType = (tab: MarketplaceTab): 'grant' | 'needs-funding' | 'bounty' | undefined => { switch (tab) { case 'grants': return 'grant'; case 'needs-funding': - return 'bounty'; + return 'needs-funding'; case 'previously-funded': return 'bounty'; default: @@ -134,13 +134,18 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { ]; const fundingProposalSortOption = [ - { value: '-unified_document__funding_proposals__amount', label: 'Amount Raised' }, - { value: 'test', label: 'Almost Funded' }, + { value: '-unified_document__fundraises__goal_amount', label: 'Goal' }, + { value: 'amount_raised', label: 'Amount Raised' }, { value: '-created_date', label: 'Created Date' }, - { value: 'unified_document__funding_proposals__end_date', label: 'Expiring soon' }, + { value: 'unified_document__fundraises__end_date', label: 'Expiring soon' }, + { value: '-unified_document__hot_score', label: 'Popular' }, ]; - const previouslyFundedSortOptions = [{ value: '-created_date', label: 'Created Date' }]; + const previouslyFundedSortOptions = [ + { value: '-unified_document__fundraises__goal_amount', label: 'Goal' }, + { value: 'amount_raised', label: 'Amount Raised' }, + { value: '-created_date', label: 'Created Date' }, + ]; const rightSidebar = marketplaceTab === 'grants' ? : ; diff --git a/components/Hub/HubSelector.tsx b/components/Hub/HubSelector.tsx index 66524b8d0..838da8c09 100644 --- a/components/Hub/HubSelector.tsx +++ b/components/Hub/HubSelector.tsx @@ -4,11 +4,10 @@ import { 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 { GrantService } from '@/services/grant.service'; +import { FeedService } from '@/services/feed.service'; import { Topic } from '@/types/topic'; export interface Hub { @@ -24,7 +23,7 @@ interface HubsSelectorProps { error?: string | null; displayCountOnly?: boolean; hideSelectedItems?: boolean; - hubType?: 'grant' | 'bounty'; + hubType?: 'grant' | 'needs-funding' | 'bounty'; } export function HubsSelector({ @@ -67,8 +66,10 @@ export function HubsSelector({ (async () => { let hubs; if (hubType === 'grant') { - hubs = await GrantService.getGrantHubs(); + hubs = await FeedService.getFeedHubs('grant_feed'); setAllHubs(hubs); + } else if (hubType === 'needs-funding') { + hubs = await FeedService.getFeedHubs('funding_feed'); } else { hubs = await BountyService.getBountyHubs(); } diff --git a/services/feed.service.ts b/services/feed.service.ts index d038c8ae7..069ff3886 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 } 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,7 +32,7 @@ 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; @@ -39,13 +54,7 @@ export class FeedService { 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 { @@ -247,4 +256,29 @@ 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); + // Use transformTopic to normalize + return response.map((raw) => ({ + id: raw.id, + name: raw.name || '', + slug: raw.slug || '', + description: raw.description, + imageUrl: raw.hub_image || undefined, + })); + } catch (error) { + console.error(`Error fetching ${endpoint} hubs`, error); + return []; + } + } } diff --git a/services/grant.service.ts b/services/grant.service.ts index b830824da..ba79e75de 100644 --- a/services/grant.service.ts +++ b/services/grant.service.ts @@ -29,21 +29,4 @@ export class GrantService { }); return response; } - - static async getGrantHubs(): Promise { - const path = `/api/grant_feed/hubs/`; - try { - const response = await ApiClient.get(path); - return response.map((raw) => ({ - id: raw.id, - name: raw.name || '', - slug: raw.slug || '', - description: raw.description, - imageUrl: raw.hub_image || undefined, - })); - } catch (error) { - console.error('Error fetching grant hubs:', error); - return []; - } - } } From f3088442488e2afd4cd6bbac419757132542bc12 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:40:25 -0600 Subject: [PATCH 05/12] manage feed entries, add sort icon --- app/fund/components/FundPageContent.tsx | 20 +++++++++++++++----- components/ui/SortDropdown.tsx | 5 +++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index 46678c3f0..3007f3cc0 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -11,8 +11,7 @@ import Icon from '@/components/ui/icons/Icon'; import { useState } from 'react'; import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; import { HubsSelector, HubsSelected, Hub } from '@/components/Hub/HubSelector'; - -import { X } from 'lucide-react'; +import { useEffect } from 'react'; interface FundPageContentProps { marketplaceTab: MarketplaceTab; @@ -21,6 +20,7 @@ interface FundPageContentProps { export function FundPageContent({ marketplaceTab }: FundPageContentProps) { const [sort, setSort] = useState('-created_date'); const [selectedHubs, setSelectedHubs] = useState([]); + const [managedEntries, setManagedEntries] = useState([]); const getFundraiseStatus = (tab: MarketplaceTab): 'OPEN' | 'CLOSED' | undefined => { if (tab === 'needs-funding' || tab === 'grants') return 'OPEN'; @@ -28,7 +28,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { 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), @@ -36,6 +36,16 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { hubIds: selectedHubs.map((h) => h.id), }); + // Manage entries separate of hook to allow for clearing on filter and sort change. + useEffect(() => { + setManagedEntries(entries); + }, [entries]); + + useEffect(() => { + setManagedEntries([]); + refresh(); + }, [sort, selectedHubs]); + const getTitle = (tab: MarketplaceTab): string => { switch (tab) { case 'grants': @@ -103,7 +113,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { hubType={getHubType(marketplaceTab)} /> -
+
setSort(opt.value)} @@ -155,7 +165,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { {}} /> = ({ 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} - + ); From 47b91d200234400dbdcd4af64cffe7a190726ccd Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:17:01 -0600 Subject: [PATCH 06/12] sort dropdown push text start --- components/ui/SortDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/SortDropdown.tsx b/components/ui/SortDropdown.tsx index e0f4c9b86..7bac78bef 100644 --- a/components/ui/SortDropdown.tsx +++ b/components/ui/SortDropdown.tsx @@ -35,7 +35,7 @@ export const SortDropdown: FC = ({ 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} ); From 2b053d1ef612f1ee4d5e2f3d40bdee19e451956b Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:21:47 -0600 Subject: [PATCH 07/12] remove dead code --- services/grant.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/grant.service.ts b/services/grant.service.ts index ba79e75de..78a56e5ab 100644 --- a/services/grant.service.ts +++ b/services/grant.service.ts @@ -1,5 +1,4 @@ import { ApiClient } from './client'; -import { Topic } from '@/types/topic'; export class GrantService { private static readonly BASE_PATH = '/api/grant'; From 23c3b78b52613ab01bca6d1f255f5227972be298 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:27:04 -0600 Subject: [PATCH 08/12] update comment --- app/fund/components/FundPageContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index 3007f3cc0..257c3c2d5 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -36,7 +36,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { hubIds: selectedHubs.map((h) => h.id), }); - // Manage entries separate of hook to allow for clearing on filter and sort change. + // Manage the entries separate from hook to allow for clearing the feed when filter and sort options change. useEffect(() => { setManagedEntries(entries); }, [entries]); From 3e32c695d7d1722ff57f70c33e42335225319fb8 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:00:42 -0600 Subject: [PATCH 09/12] remove dead code --- app/earn/page.tsx | 8 +- app/fund/components/FundPageContent.tsx | 5 +- components/Hub/HubSelector.tsx | 111 +++++++----------------- 3 files changed, 32 insertions(+), 92 deletions(-) diff --git a/app/earn/page.tsx b/app/earn/page.tsx index 9448fbc13..2aec26d5c 100644 --- a/app/earn/page.tsx +++ b/app/earn/page.tsx @@ -119,13 +119,7 @@ export default function EarnPage() { {/* Top filter bar */}
- +
diff --git a/components/Hub/HubSelector.tsx b/components/Hub/HubSelector.tsx index 838da8c09..f097b15c4 100644 --- a/components/Hub/HubSelector.tsx +++ b/components/Hub/HubSelector.tsx @@ -21,8 +21,6 @@ interface HubsSelectorProps { selectedHubs: Hub[]; onChange: (hubs: Hub[]) => void; error?: string | null; - displayCountOnly?: boolean; - hideSelectedItems?: boolean; hubType?: 'grant' | 'needs-funding' | 'bounty'; } @@ -30,10 +28,8 @@ export function HubsSelector({ selectedHubs, onChange, error, - displayCountOnly = false, - hideSelectedItems = false, hubType, -}: HubsSelectorProps) { +}: Readonly) { const [allHubs, setAllHubs] = useState([]); const [menuOpen, setMenuOpen] = useState(false); const menuContentRef = useRef(null); @@ -99,7 +95,7 @@ export function HubsSelector({ const allHubOptions = hubsToOptions(topicsToHubs(allHubs)); - // Local search within allHubs + // Local search within all Hubs const filterHubs = useCallback( async (query: string): Promise => { if (!query) { @@ -114,104 +110,57 @@ export function HubsSelector({ 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) => ( From 0a1d6ae0aeb26b6c2765ac9e241b32e643948ff6 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:31:15 -0600 Subject: [PATCH 10/12] partial refactor of useFeed, update feed.service --- hooks/useFeed.ts | 85 ++++++++++++---------------------------- services/feed.service.ts | 11 +----- 2 files changed, 28 insertions(+), 68 deletions(-) diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts index 4b1ef7574..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'; @@ -31,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(() => { @@ -57,66 +70,16 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions } }, [status, activeTab]); - const arraysEqual = (a?: (string | number)[], b?: (string | number)[]) => { - if (a === b) return true; - if (!a || !b) return false; - if (a.length !== b.length) return false; - return a.every((val, i) => val === b[i]); - }; - - // 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 || - !arraysEqual(options.hubIds, currentOptions.hubIds); - - 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, - hubIds: options.hubIds, - }); - 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, @@ -128,11 +91,15 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions 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); } @@ -142,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 069ff3886..092d2e778 100644 --- a/services/feed.service.ts +++ b/services/feed.service.ts @@ -4,7 +4,7 @@ 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 } from '@/types/topic'; +import { Topic, transformTopic } from '@/types/topic'; type Endpoints = 'feed' | 'funding_feed' | 'grant_feed' | undefined; @@ -268,14 +268,7 @@ export class FeedService { try { const response = await ApiClient.get(path); - // Use transformTopic to normalize - return response.map((raw) => ({ - id: raw.id, - name: raw.name || '', - slug: raw.slug || '', - description: raw.description, - imageUrl: raw.hub_image || undefined, - })); + return response.map((raw) => transformTopic(raw)); } catch (error) { console.error(`Error fetching ${endpoint} hubs`, error); return []; From ed1e637c367bac28a542ec52b1933b3bf89d4679 Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:00:21 -0600 Subject: [PATCH 11/12] add most applications and most reviews filters, make filter case consistent --- app/fund/components/FundPageContent.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index 04dcee124..b2cb631b1 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -136,22 +136,24 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { // Available sort options const grantSortOptions = [ { value: '-unified_document__grants__amount', label: 'Amount' }, - { value: '-created_date', label: 'Created Date' }, + { value: '-created_date', label: 'Created date' }, { value: 'unified_document__grants__end_date', label: 'Expiring soon' }, + { value: 'application_count', label: 'Most applications' }, ]; const fundingProposalSortOption = [ { value: '-unified_document__fundraises__goal_amount', label: 'Goal' }, - { value: 'amount_raised', label: 'Amount Raised' }, - { value: '-created_date', label: 'Created Date' }, + { value: 'amount_raised', label: 'Amount raised' }, + { value: '-created_date', label: 'Created date' }, { value: 'unified_document__fundraises__end_date', label: 'Expiring soon' }, { value: '-unified_document__hot_score', label: 'Popular' }, + { value: 'review_count', label: 'Most reviews' }, ]; const previouslyFundedSortOptions = [ { value: '-unified_document__fundraises__goal_amount', label: 'Goal' }, - { value: 'amount_raised', label: 'Amount Raised' }, - { value: '-created_date', label: 'Created Date' }, + { value: 'amount_raised', label: 'Amount raised' }, + { value: '-created_date', label: 'Created date' }, ]; const rightSidebar = marketplaceTab === 'grants' ? : ; From b7e8eab765eaf77faef5cefad7a3c03f2fa28bcf Mon Sep 17 00:00:00 2001 From: Dan <39170265+chillenberger@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:51:15 -0600 Subject: [PATCH 12/12] clean up sorts and FundPageContent consts --- app/fund/components/FundPageContent.tsx | 133 ++++++++++-------------- app/layouts/TopBar.tsx | 1 + 2 files changed, 56 insertions(+), 78 deletions(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index b2cb631b1..3917f24e6 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -12,12 +12,61 @@ 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('-created_date'); + const [sort, setSort] = useState(DEFAULT_SORT_MAP[marketplaceTab]); const [selectedHubs, setSelectedHubs] = useState([]); const [managedEntries, setManagedEntries] = useState([]); @@ -45,56 +94,6 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { refresh(); }, [sort, selectedHubs]); - 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 ''; - } - }; - - 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 ''; - } - }; - - const getSortOptions = (tab: MarketplaceTab): SortOption[] => { - switch (tab) { - case 'grants': - return grantSortOptions; - case 'needs-funding': - return fundingProposalSortOption; - case 'previously-funded': - return previouslyFundedSortOptions; - } - }; - - const getHubType = (tab: MarketplaceTab): 'grant' | 'needs-funding' | 'bounty' | undefined => { - switch (tab) { - case 'grants': - return 'grant'; - case 'needs-funding': - return 'needs-funding'; - case 'previously-funded': - return 'bounty'; - default: - return undefined; - } - }; - const handleHubsChange = (hubs: any[]) => { setSelectedHubs(hubs); }; @@ -107,14 +106,14 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) {
setSort(opt.value)} - options={getSortOptions(marketplaceTab)} + options={SORT_OPTIONS_MAP[marketplaceTab]} />
@@ -125,37 +124,15 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) {
); + // 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]} /> ); - // Available sort options - const grantSortOptions = [ - { value: '-unified_document__grants__amount', label: 'Amount' }, - { value: '-created_date', label: 'Created date' }, - { value: 'unified_document__grants__end_date', label: 'Expiring soon' }, - { value: 'application_count', label: 'Most applications' }, - ]; - - const fundingProposalSortOption = [ - { value: '-unified_document__fundraises__goal_amount', label: 'Goal' }, - { value: 'amount_raised', label: 'Amount raised' }, - { value: '-created_date', label: 'Created date' }, - { value: 'unified_document__fundraises__end_date', label: 'Expiring soon' }, - { value: '-unified_document__hot_score', label: 'Popular' }, - { value: 'review_count', label: 'Most reviews' }, - ]; - - const previouslyFundedSortOptions = [ - { value: '-unified_document__fundraises__goal_amount', label: 'Goal' }, - { value: 'amount_raised', label: 'Amount raised' }, - { value: '-created_date', label: 'Created date' }, - ]; - const rightSidebar = marketplaceTab === 'grants' ? : ; return ( 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',