diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index adf5c1601..03a7a7477 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { PageLayout } from '@/app/layouts/PageLayout'; import { useFeed } from '@/hooks/useFeed'; @@ -7,14 +8,16 @@ import { useFeedTabs } from '@/hooks/useFeedTabs'; import { FeedContent } from '@/components/Feed/FeedContent'; import { FeedTabs } from '@/components/Feed/FeedTabs'; import { FundRightSidebar } from '@/components/Fund/FundRightSidebar'; -import { GrantRightSidebar } from '@/components/Fund/GrantRightSidebar'; +import { FundingMobileInfo } from '@/components/Fund/FundingMobileInfo'; +import { FundingPromotionCards } from '@/components/Fund/FundingPromotionCards'; +import { FundingFilters } from '@/components/Fund/FundingFilters'; import { MainPageHeader } from '@/components/ui/MainPageHeader'; import { FundingSortOption } from '@/components/Fund/MarketplaceTabs'; import Icon from '@/components/ui/icons/Icon'; import { createTabConfig, getSortOptions } from '@/components/Fund/lib/FundingFeedConfig'; interface FundPageContentProps { - marketplaceTab: 'grants' | 'needs-funding'; + marketplaceTab: 'all' | 'opportunities' | 'needs-funding'; } export function FundPageContent({ marketplaceTab }: FundPageContentProps) { @@ -25,24 +28,51 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { const defaultSort = marketplaceTab === 'needs-funding' ? 'best' : 'newest'; const sortBy = (searchParams.get('ordering') as FundingSortOption) || defaultSort; - const TAB_CONFIG = createTabConfig(, ); + const TAB_CONFIG = createTabConfig(); const config = TAB_CONFIG[marketplaceTab]; - const sortOptions = getSortOptions(marketplaceTab); + + // Filter state derived from URL + const status = searchParams.get('status') || 'all'; + const peerReview = searchParams.get('peer_review') || 'all'; + const taxDeductible = searchParams.get('tax_deductible') === 'true'; + + const updateParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === '') { + params.delete(key); + } else { + params.set(key, value); + } + }); + router.push(`?${params.toString()}`, { scroll: false }); + }, + [searchParams, router] + ); const handleSortChange = (newSort: FundingSortOption) => { - const params = new URLSearchParams(searchParams.toString()); - if (newSort) { - params.set('ordering', newSort); - } else { - params.delete('ordering'); - } - router.push(`?${params.toString()}`, { scroll: false }); + updateParams({ ordering: newSort || null }); + }; + + const handleStatusChange = (value: string) => { + updateParams({ status: value === 'all' ? null : value }); }; - // When "completed" is selected, fetch closed fundraises with newest ordering - const isCompleted = sortBy === 'completed'; - const effectiveFundraiseStatus = isCompleted ? 'CLOSED' : config.fundraiseStatus; - const effectiveOrdering = isCompleted ? 'newest' : sortBy || undefined; + const handlePeerReviewChange = (value: string) => { + updateParams({ peer_review: value === 'all' ? null : value }); + }; + + const handleTaxDeductibleToggle = () => { + updateParams({ tax_deductible: taxDeductible ? null : 'true' }); + }; + + // Derive effective fundraise status from pill filter + sort + const isCompletedSort = sortBy === 'completed'; + const isCompletedFilter = status === 'completed'; + const showClosed = isCompletedSort || isCompletedFilter; + const effectiveFundraiseStatus = showClosed ? 'CLOSED' : config.fundraiseStatus; + const effectiveOrdering = isCompletedSort ? 'newest' : sortBy || undefined; const { entries, @@ -67,18 +97,37 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { subtitle={config.subtitle} showTitle={false} /> -
+ {activeTab === 'needs-funding' && ( +
+ +
+ )} +
handleSortChange(sort as any)} - sortOptions={sortOptions} />
+ {/* Pill filters */} + + {/* Mobile-only: Horizontal scrolling CTAs and info drawer */} + {activeTab === 'needs-funding' && ( +
+ +
+ )} ; + return ; } diff --git a/app/fund/opportunities/page.tsx b/app/fund/opportunities/page.tsx new file mode 100644 index 000000000..4b5abcab7 --- /dev/null +++ b/app/fund/opportunities/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { FundPageContent } from '../components/FundPageContent'; + +export default function OpportunitiesPage() { + return ; +} diff --git a/app/fund/page.tsx b/app/fund/page.tsx index 802272260..3051598ac 100644 --- a/app/fund/page.tsx +++ b/app/fund/page.tsx @@ -2,7 +2,7 @@ import { FundPageContent } from './components/FundPageContent'; -// Show grants by default +// Default to needs-funding tab export default function FundingPage() { - return ; + return ; } diff --git a/app/grant/[id]/[slug]/applications/page.tsx b/app/grant/[id]/[slug]/applications/page.tsx index b1e72cdd5..a47696ff4 100644 --- a/app/grant/[id]/[slug]/applications/page.tsx +++ b/app/grant/[id]/[slug]/applications/page.tsx @@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Props): Promise { const grant = await getGrant(resolvedParams.id); return getWorkMetadata({ work: grant, - url: `/grant/${resolvedParams.id}/${resolvedParams.slug}/applications`, + url: `/opportunity/${resolvedParams.id}/${resolvedParams.slug}/applications`, titleSuffix: 'Applications', }); } diff --git a/app/grant/[id]/[slug]/conversation/page.tsx b/app/grant/[id]/[slug]/conversation/page.tsx index a0c296d9b..b42a31514 100644 --- a/app/grant/[id]/[slug]/conversation/page.tsx +++ b/app/grant/[id]/[slug]/conversation/page.tsx @@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Props): Promise { const grant = await getGrant(resolvedParams.id); return getWorkMetadata({ work: grant, - url: `/grant/${resolvedParams.id}/${resolvedParams.slug}/conversation`, + url: `/opportunity/${resolvedParams.id}/${resolvedParams.slug}/conversation`, titleSuffix: 'Conversation', }); } diff --git a/app/grant/[id]/[slug]/page.tsx b/app/grant/[id]/[slug]/page.tsx index d915aafcd..611625073 100644 --- a/app/grant/[id]/[slug]/page.tsx +++ b/app/grant/[id]/[slug]/page.tsx @@ -47,7 +47,7 @@ export async function generateMetadata({ params }: Props): Promise { const grant = await getGrant(resolvedParams.id); return getWorkMetadata({ work: grant, - url: `/grant/${resolvedParams.id}/${resolvedParams.slug}`, + url: `/opportunity/${resolvedParams.id}/${resolvedParams.slug}`, }); } diff --git a/app/grant/[id]/page.tsx b/app/grant/[id]/page.tsx index 56a3059eb..27fafa44c 100644 --- a/app/grant/[id]/page.tsx +++ b/app/grant/[id]/page.tsx @@ -24,5 +24,5 @@ export default async function GrantRedirectPage({ params }: Props) { } // Redirect to the full URL with slug (outside try-catch) - handleMissingSlugRedirect(grant, id, 'grant'); + handleMissingSlugRedirect(grant, id, 'opportunity'); } diff --git a/app/layouts/MobileBottomNav.tsx b/app/layouts/MobileBottomNav.tsx index 1d5046994..d01a1c548 100644 --- a/app/layouts/MobileBottomNav.tsx +++ b/app/layouts/MobileBottomNav.tsx @@ -48,8 +48,8 @@ const isPathActive = (path: string, currentPath: string): boolean => { if (path === '/for-you' || path === '/popular') { return ['/popular', '/for-you', '/latest', '/following', '/'].includes(currentPath); } - if (path === '/fund/grants') { - return ['/fund/grants', '/fund/needs-funding'].includes(currentPath); + if (path === '/fund/needs-funding') { + return currentPath.startsWith('/fund') || currentPath.startsWith('/opportunity'); } if (path === '/notebook') { return currentPath.startsWith('/notebook'); @@ -86,7 +86,7 @@ export const MobileBottomNav: React.FC = () => { const mainNavItems: NavItem[] = [ { label: 'Home', href: homeHref, iconKey: 'home', isDynamicHome: true }, { label: 'Earn', href: '/earn', iconKey: 'earn' }, - { label: 'Fund', href: '/fund/grants', iconKey: 'fund' }, + { label: 'Fund', href: '/fund/needs-funding', iconKey: 'fund' }, { label: 'Wallet', href: '/researchcoin', iconKey: 'wallet' }, { label: 'More', isMore: true, iconKey: 'more' }, ]; diff --git a/app/layouts/Navigation.tsx b/app/layouts/Navigation.tsx index 96f3dc6bf..274045d0a 100644 --- a/app/layouts/Navigation.tsx +++ b/app/layouts/Navigation.tsx @@ -9,12 +9,10 @@ import { IconName } from '@/components/ui/icons/Icon'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHouse as faHouseSolid, - faGrid3 as faGrid3Solid, faBookmark as faBookmarkSolid, } from '@fortawesome/pro-solid-svg-icons'; import { faHouse as faHouseLight, - faGrid3 as faGrid3Light, faBookmark as faBookmarkLight, } from '@fortawesome/pro-light-svg-icons'; import { ChartNoAxesColumnIncreasing } from 'lucide-react'; @@ -100,10 +98,10 @@ export const Navigation: React.FC = ({ description: 'Navigate to the home page', }, { - label: 'Browse', - href: '/browse', - description: 'Browse topics by category', - isFontAwesome: true, + label: 'Fund', + href: '/fund/needs-funding', + iconKey: 'fund', + description: 'Browse funding and research opportunities', }, { label: 'Earn', @@ -111,12 +109,6 @@ export const Navigation: React.FC = ({ iconKey: 'earn', description: 'Earn RSC for completing peer reviews', }, - { - label: 'Fund', - href: '/fund/grants', - iconKey: 'fund', - description: 'Browse grants and fundraising opportunities', - }, { label: 'RH Journal', href: '/journal', @@ -163,9 +155,9 @@ export const Navigation: React.FC = ({ return ['/popular', '/for-you', '/latest', '/following'].includes(currentPath); } - // Special case for fund page - match specific fund routes - if (path === '/fund/grants') { - return ['/fund/grants', '/fund/needs-funding'].includes(currentPath); + // Special case for fund page - match all funding pages + if (path === '/fund/needs-funding') { + return currentPath.startsWith('/fund') || currentPath.startsWith('/opportunity'); } // Special case for notebook page - match any route that starts with /notebook @@ -178,11 +170,6 @@ export const Navigation: React.FC = ({ return currentPath.startsWith('/leaderboard'); } - // Special case for browse page - if (path === '/browse') { - return currentPath.startsWith('/browse'); - } - // Special case for lists page - match /lists and /list/[id] if (path === '/lists') { return currentPath === '/lists' || currentPath.startsWith('/list/'); @@ -248,12 +235,6 @@ export const Navigation: React.FC = ({ color={iconColor} strokeWidth={isActive ? 2.5 : 2} /> - ) : item.href === '/browse' ? ( - ) : item.href === '/lists' ? ( , - action: 'navigate', - path: '/paper/create', - requiresAuth: true, - }, - { - id: 'write-note', - title: 'Write a research note', - description: 'Share insights, ideas, or work in progress', - icon: , - action: 'navigate', - path: '/notebook', - requiresAuth: true, - }, - ], + id: 'request-funding', + title: 'Post research proposal', + description: 'Get crowdfunding for your experiments', + icon: , + action: 'function', + handler: 'handleFundResearch', + requiresAuth: true, }, { - title: 'ResearchCoin Economy', - items: [ - { - id: 'request-funding', - title: 'Request funding', - description: 'Get crowdfunding for your experiments', - icon: , - action: 'function', - handler: 'handleFundResearch', - requiresAuth: true, - }, - { - id: 'give-funding', - title: 'Give research funding', - description: 'Fund specific research you care about', - icon: , - action: 'function', - handler: 'handleOpenGrant', - requiresAuth: true, - }, - { - id: 'post-bounty', - title: 'Post a bounty', - description: 'Pay experts to solve your problems', - icon: , - action: 'function', - handler: 'handleCreateBounty', - requiresAuth: true, - }, - ], + id: 'give-funding', + title: 'Post funding opportunity', + description: 'Fund specific research you care about', + icon: , + action: 'function', + handler: 'handleOpenGrant', + requiresAuth: true, + }, + { + id: 'submit-paper', + title: 'Post manuscript', + description: 'Submit a manuscript as a preprint or publication', + icon: , + action: 'navigate', + path: '/paper/create', + requiresAuth: true, }, ] as const; @@ -100,7 +70,6 @@ const MenuItemContent: React.FC = ({ icon, title, descript export const PublishMenu: React.FC = ({ children, forceMinimize = false }) => { const router = useRouter(); const { executeAuthenticatedAction } = useAuthenticatedAction(); - const { user } = useUser(); const { smAndDown } = useScreenSize(); const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); @@ -112,15 +81,7 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz router.push('/notebook?newGrant=true'); }; - const handleCreateBounty = () => { - router.push('/bounty/create'); - }; - - const handleViewProfile = () => { - navigateToAuthorProfile(user?.id, false); - }; - - const handleMenuItemClick = (item: (typeof PUBLISH_MENU_SECTIONS)[number]['items'][number]) => { + const handleMenuItemClick = (item: (typeof PUBLISH_MENU_ITEMS)[number]) => { if (item.requiresAuth) { executeAuthenticatedAction(() => { if (item.action === 'navigate') { @@ -133,9 +94,6 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz case 'handleOpenGrant': handleOpenGrant(); break; - case 'handleCreateBounty': - handleCreateBounty(); - break; } } }); @@ -185,70 +143,54 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz ); const menuContent = ( -
- {PUBLISH_MENU_SECTIONS.map((section) => ( -
-
-

- {section.title} -

-
-
- {section.items.map((item) => ( - handleMenuItemClick(item)} - className="w-full px-2" - > - - - ))} -
-
- ))} +
+
+

+ Post on ResearchHub +

+
+
+ {PUBLISH_MENU_ITEMS.map((item) => ( + handleMenuItemClick(item)} + className="w-full px-2" + > + + + ))} +
); // Mobile drawer content const mobileDrawerContent = ( -
- {PUBLISH_MENU_SECTIONS.map((section) => ( -
-
-

- {section.title} -

-
-
- {section.items.map((item) => ( -
handleMenuItemClick(item)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleMenuItemClick(item); - } - }} - className="w-full px-3 py-3 hover:bg-gray-50 cursor-pointer rounded-lg" - role="button" - tabIndex={0} - aria-label={`${item.title}: ${item.description}`} - > - -
- ))} +
+
+

+ Post on ResearchHub +

+
+
+ {PUBLISH_MENU_ITEMS.map((item) => ( +
handleMenuItemClick(item)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleMenuItemClick(item); + } + }} + className="w-full px-3 py-3 hover:bg-gray-50 cursor-pointer rounded-lg" + role="button" + tabIndex={0} + aria-label={`${item.title}: ${item.description}`} + > +
-
- ))} + ))} +
); diff --git a/app/layouts/TopBar.tsx b/app/layouts/TopBar.tsx index 46c7ebefa..6f46a3e04 100644 --- a/app/layouts/TopBar.tsx +++ b/app/layouts/TopBar.tsx @@ -59,7 +59,7 @@ const isRootNavigationPage = (pathname: string): boolean => { '/feed', '/earn', '/fund', - '/fund/grants', + '/fund/opportunities', '/fund/needs-funding', // Fundraises page '/journal', '/notebook', @@ -188,34 +188,18 @@ const getPageInfo = (pathname: string): PageInfo | null => { }; } - if (pathname.startsWith('/fund/grants')) { - return { - title: 'Request for Proposals', - subtitle: 'Explore available funding opportunities', - icon: , - }; - } - - if (pathname === '/fund/needs-funding') { - return { - title: 'Research Proposals', - subtitle: 'Support research projects seeking funding', - icon: , - }; - } - if (pathname.startsWith('/fund')) { return { - title: 'Funding', - subtitle: 'Fund breakthrough research shaping tomorrow', + title: 'Funding Marketplace', + subtitle: 'Participate in funding the future of science', icon: , }; } - // Grant routes - if (pathname.startsWith('/grant')) { + // Opportunity routes (individual opportunity pages) + if (pathname.startsWith('/opportunity')) { return { - title: 'RFP', + title: 'Funding Opportunity', icon: , }; } diff --git a/app/notebook/components/PublishingForm/components/PublishedStatusSection.tsx b/app/notebook/components/PublishingForm/components/PublishedStatusSection.tsx index 10e62f238..128836ce9 100644 --- a/app/notebook/components/PublishingForm/components/PublishedStatusSection.tsx +++ b/app/notebook/components/PublishingForm/components/PublishedStatusSection.tsx @@ -28,7 +28,7 @@ export function PublishedStatusSection() { articleType === 'preregistration' ? `/fund/${workId}/${slug}` : articleType === 'funding_request' - ? `/grant/${workId}/${slug}` + ? `/opportunity/${workId}/${slug}` : `/post/${workId}/${slug}` } className="text-gray-400 hover:text-gray-600 transition-colors" diff --git a/app/notebook/components/PublishingForm/index.tsx b/app/notebook/components/PublishingForm/index.tsx index 0ce3a4c36..006a987c7 100644 --- a/app/notebook/components/PublishingForm/index.tsx +++ b/app/notebook/components/PublishingForm/index.tsx @@ -431,7 +431,7 @@ export function PublishingForm({ bountyAmount, onBountyClick }: PublishingFormPr if (formData.articleType === 'preregistration') { router.push(`/fund/${response.id}/${response.slug}?new=true`); } else if (formData.articleType === 'grant') { - router.push(`/grant/${response.id}/${response.slug}`); + router.push(`/opportunity/${response.id}/${response.slug}`); } else { router.push(`/post/${response.id}/${response.slug}`); } diff --git a/app/opportunity/999/demo/page.tsx b/app/opportunity/999/demo/page.tsx new file mode 100644 index 000000000..29166d524 --- /dev/null +++ b/app/opportunity/999/demo/page.tsx @@ -0,0 +1,19 @@ +import { PageLayout } from '@/app/layouts/PageLayout'; +import { GrantRightSidebar } from '@/components/work/GrantRightSidebar'; +import { GrantDocument } from '@/components/work/GrantDocument'; +import { Suspense } from 'react'; +import { SearchHistoryTracker } from '@/components/work/SearchHistoryTracker'; +import { defaultMockGrant } from '@/store/grantStore'; + +export default function DemoOpportunityPage() { + const { work: mockWork, metadata: mockMetadata } = defaultMockGrant; + + return ( + }> + + + + + + ); +} diff --git a/app/opportunity/[id]/[slug]/applications/page.tsx b/app/opportunity/[id]/[slug]/applications/page.tsx new file mode 100644 index 000000000..059360f92 --- /dev/null +++ b/app/opportunity/[id]/[slug]/applications/page.tsx @@ -0,0 +1,77 @@ +import { Suspense } from 'react'; +import { PostService } from '@/services/post.service'; +import { MetadataService } from '@/services/metadata.service'; +import { Work } from '@/types/work'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { GrantDocument } from '@/components/work/GrantDocument'; +import { GrantRightSidebar } from '@/components/work/GrantRightSidebar'; +import { SearchHistoryTracker } from '@/components/work/SearchHistoryTracker'; +import { WorkDocumentTracker } from '@/components/WorkDocumentTracker'; +import { getWorkMetadata } from '@/lib/metadata-helpers'; + +interface Props { + params: Promise<{ + id: string; + slug: string; + }>; +} + +async function getOpportunity(id: string): Promise { + if (!id.match(/^\d+$/)) { + notFound(); + } + + try { + const work = await PostService.get(id); + return work; + } catch (error) { + notFound(); + } +} + +async function getWorkHTMLContent(work: Work): Promise { + if (!work.contentUrl) return undefined; + + try { + return await PostService.getContent(work.contentUrl); + } catch (error) { + console.error('Failed to fetch content:', error); + return undefined; + } +} + +export async function generateMetadata({ params }: Props): Promise { + const resolvedParams = await params; + const opportunity = await getOpportunity(resolvedParams.id); + return getWorkMetadata({ + work: opportunity, + url: `/opportunity/${resolvedParams.id}/${resolvedParams.slug}/applications`, + titleSuffix: 'Applications', + }); +} + +export default async function OpportunityApplicationsPage({ params }: Props) { + const resolvedParams = await params; + const id = resolvedParams.id; + + const work = await getOpportunity(id); + const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); + const content = await getWorkHTMLContent(work); + + return ( + }> + + + + + + + ); +} diff --git a/app/opportunity/[id]/[slug]/conversation/page.tsx b/app/opportunity/[id]/[slug]/conversation/page.tsx new file mode 100644 index 000000000..9579d3cba --- /dev/null +++ b/app/opportunity/[id]/[slug]/conversation/page.tsx @@ -0,0 +1,77 @@ +import { Suspense } from 'react'; +import { PostService } from '@/services/post.service'; +import { MetadataService } from '@/services/metadata.service'; +import { Work } from '@/types/work'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { GrantDocument } from '@/components/work/GrantDocument'; +import { GrantRightSidebar } from '@/components/work/GrantRightSidebar'; +import { SearchHistoryTracker } from '@/components/work/SearchHistoryTracker'; +import { WorkDocumentTracker } from '@/components/WorkDocumentTracker'; +import { getWorkMetadata } from '@/lib/metadata-helpers'; + +interface Props { + params: Promise<{ + id: string; + slug: string; + }>; +} + +async function getOpportunity(id: string): Promise { + if (!id.match(/^\d+$/)) { + notFound(); + } + + try { + const work = await PostService.get(id); + return work; + } catch (error) { + notFound(); + } +} + +async function getWorkHTMLContent(work: Work): Promise { + if (!work.contentUrl) return undefined; + + try { + return await PostService.getContent(work.contentUrl); + } catch (error) { + console.error('Failed to fetch content:', error); + return undefined; + } +} + +export async function generateMetadata({ params }: Props): Promise { + const resolvedParams = await params; + const opportunity = await getOpportunity(resolvedParams.id); + return getWorkMetadata({ + work: opportunity, + url: `/opportunity/${resolvedParams.id}/${resolvedParams.slug}/conversation`, + titleSuffix: 'Conversation', + }); +} + +export default async function OpportunityConversationPage({ params }: Props) { + const resolvedParams = await params; + const id = resolvedParams.id; + + const work = await getOpportunity(id); + const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); + const content = await getWorkHTMLContent(work); + + return ( + }> + + + + + + + ); +} diff --git a/app/opportunity/[id]/[slug]/page.tsx b/app/opportunity/[id]/[slug]/page.tsx new file mode 100644 index 000000000..5c32daa22 --- /dev/null +++ b/app/opportunity/[id]/[slug]/page.tsx @@ -0,0 +1,71 @@ +import { Suspense } from 'react'; +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { PostService } from '@/services/post.service'; +import { MetadataService } from '@/services/metadata.service'; +import { Work } from '@/types/work'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { SearchHistoryTracker } from '@/components/work/SearchHistoryTracker'; +import { WorkDocumentTracker } from '@/components/WorkDocumentTracker'; +import { GrantDocument } from '@/components/work/GrantDocument'; +import { GrantRightSidebar } from '@/components/work/GrantRightSidebar'; +import { getWorkMetadata } from '@/lib/metadata-helpers'; + +interface Props { + params: Promise<{ + id: string; + slug: string; + }>; +} +async function getWorkHTMLContent(work: Work): Promise { + if (!work.contentUrl) return undefined; + + try { + return await PostService.getContent(work.contentUrl); + } catch (error) { + console.error('Failed to fetch content:', error); + return undefined; + } +} + +async function getOpportunity(id: string): Promise { + if (!id.match(/^\d+$/)) { + notFound(); + } + + try { + const work = await PostService.get(id); + return work; + } catch (error) { + notFound(); + } +} + +export async function generateMetadata({ params }: Props): Promise { + const resolvedParams = await params; + const opportunity = await getOpportunity(resolvedParams.id); + return getWorkMetadata({ + work: opportunity, + url: `/opportunity/${resolvedParams.id}/${resolvedParams.slug}`, + }); +} + +export default async function OpportunityPage({ params }: Props) { + const resolvedParams = await params; + const id = resolvedParams.id; + + const work = await getOpportunity(id); + const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); + + const content = await getWorkHTMLContent(work); + + return ( + }> + + + + + + + ); +} diff --git a/app/opportunity/[id]/page.tsx b/app/opportunity/[id]/page.tsx new file mode 100644 index 000000000..e2ea7b933 --- /dev/null +++ b/app/opportunity/[id]/page.tsx @@ -0,0 +1,28 @@ +import { notFound } from 'next/navigation'; +import { PostService } from '@/services/post.service'; +import { handleMissingSlugRedirect } from '@/utils/navigation'; + +interface Props { + params: Promise<{ + id: string; + }>; +} + +export default async function OpportunityRedirectPage({ params }: Props) { + const resolvedParams = await params; + const id = resolvedParams.id; + + if (!id.match(/^\d+$/)) { + notFound(); + } + + let grant; + try { + grant = await PostService.get(id); + } catch (error) { + notFound(); + } + + // Redirect to the full URL with slug (outside try-catch) + handleMissingSlugRedirect(grant, id, 'opportunity'); +} diff --git a/app/opportunity/create/page.tsx b/app/opportunity/create/page.tsx new file mode 100644 index 000000000..856a3c355 --- /dev/null +++ b/app/opportunity/create/page.tsx @@ -0,0 +1,339 @@ +'use client'; + +import React, { useState } from 'react'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { ResearchCoinRightSidebar } from '@/components/ResearchCoin/ResearchCoinRightSidebar'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/form/Input'; +import { Textarea } from '@/components/ui/form/Textarea'; +import { Dropdown, DropdownItem } from '@/components/ui/form/Dropdown'; +import { + DollarSign, + CalendarDays, + Users, + Mail, + Phone, + Target, + ChevronDown, + HelpCircle, + icons, + CheckCircle, +} from 'lucide-react'; + +type FundingTimelineOptions = '6m_less' | '6m_1y' | '1y_more'; +type ContactPreferenceOptions = 'email' | 'phone'; + +export default function CreateOpportunityPage() { + const [showForm, setShowForm] = useState(false); + const [objectives, setObjectives] = useState(''); + const [fundingAmount, setFundingAmount] = useState(''); + const [fundingTimeline, setFundingTimeline] = useState(''); + const [needsExpertHelp, setNeedsExpertHelp] = useState(false); + const [contactPreference, setContactPreference] = useState(''); + const [contactDetail, setContactDetail] = useState(''); + + const fundingTimelineLabels: Record = { + '6m_less': '6 months or less', + '6m_1y': '6 months - 1 year', + '1y_more': 'Over a year', + }; + + const handleSubmit = () => { + console.log({ + objectives, + fundingAmount, + fundingTimeline, + needsExpertHelp, + contactPreference, + contactDetail, + }); + }; + + return ( + }> +
+
+
+

Create a Funding Opportunity

+

+ Accelerate scientific research with a funding opportunity. +

+
+
+ + {!showForm && ( + <> +
+ +
+ +
+
+

+ Why fund with ResearchHub? +

+
+ {[ + { + title: 'Fast Turnaround', + text: 'See high-quality applicants quickly and efficiently.', + iconName: 'Rocket', + style: { + iconColor: 'text-black-500', + titleColor: 'text-black-700', + }, + }, + { + title: 'Quality Researchers', + text: 'Access a global network of vetted, reputable scientists and academics.', + iconName: 'Users', + style: { + iconColor: 'text-black-500', + titleColor: 'text-black-700', + }, + }, + { + title: 'Fully Open & Transparent', + text: 'Applicants submit pre-registrations, ensuring clarity and openness from the start.', + iconName: 'FileText', + style: { + iconColor: 'text-black-500', + titleColor: 'text-black-700', + }, + }, + { + title: 'Low Overhead', + text: 'Maximize your impact with our streamlined, cost-effective platform.', + iconName: 'TrendingDown', + style: { + iconColor: 'text-black-500', + titleColor: 'text-black-700', + }, + }, + { + title: 'Tax Deductible', + text: 'Contributions may be tax-deductible. (Consult your tax advisor).', + iconName: 'ShieldCheck', + style: { + iconColor: 'text-black-500', + titleColor: 'text-black-700', + }, + }, + { + title: 'Expert Support', + text: 'We assign a dedicated contact person to ensure your process is smooth and successful.', + iconName: 'LifeBuoy', + style: { + iconColor: 'text-black-500', + titleColor: 'text-black-700', + }, + }, + ].map((benefit, index) => { + const LucideIcon = icons[benefit.iconName as keyof typeof icons] || CheckCircle; + const itemStyle = benefit.style; + return ( +
+ +
+

+ {benefit.title} +

+

{benefit.text}

+
+
+ ); + })} +
+
+
+ + )} + + {showForm && ( + <> +
+
+
+ +

Objectives

+
+

+ Clearly outline the primary goals and aims of your funding opportunity. +

+