diff --git a/components/Feed/items/FeedItemGrantWithApplicants.tsx b/components/Feed/items/FeedItemGrantWithApplicants.tsx index 7677c442b..282052982 100644 --- a/components/Feed/items/FeedItemGrantWithApplicants.tsx +++ b/components/Feed/items/FeedItemGrantWithApplicants.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { FeedEntry, FeedGrantContent } from '@/types/feed'; import { Avatar } from '@/components/ui/Avatar'; import { Button } from '@/components/ui/Button'; +import { ButtonGroup } from '@/components/ui/ButtonGroup'; import { ArrowRight, CalendarOff, Star } from 'lucide-react'; import { cn } from '@/utils/styles'; import { RadiatingDot } from '@/components/ui/RadiatingDot'; @@ -20,6 +21,10 @@ import { KeyInsightsModal } from '@/components/modals/KeyInsightsModal'; import { KeyInsightsLine } from '@/components/work/KeyInsights/KeyInsightsLine'; import { KeyInsightsPanel } from '@/components/work/KeyInsights/KeyInsightsPanel'; import { useIsFunderDashboard } from '@/components/work/KeyInsights/useIsFunderDashboard'; +import { GrantInvitedExpertsSection } from '@/components/Funding/GrantInvitedExpertsSection'; +import { useUser } from '@/contexts/UserContext'; +import type { AuthorProfile } from '@/types/authorProfile'; +import type { User } from '@/types/user'; interface FeedItemGrantWithApplicantsProps { entry: FeedEntry; @@ -28,6 +33,26 @@ interface FeedItemGrantWithApplicantsProps { const VISIBLE_PROPOSALS = 3; +function canViewGrantInvitedExperts( + user: User | null | undefined, + createdBy: AuthorProfile +): boolean { + if (!user) return false; + if (user.isModerator) return true; + + const creatorUserId = createdBy.userId ?? createdBy.user?.id; + if (creatorUserId != null && creatorUserId === user.id) { + return true; + } + + const authorId = user.authorProfile?.id; + if (authorId != null && authorId > 0 && authorId === createdBy.id) { + return true; + } + + return false; +} + function formatCompact(amount: number, showUSD: boolean, exchangeRate: number): string { return formatCurrency({ amount, showUSD, exchangeRate, skipConversion: true, shorten: true }); } @@ -180,7 +205,10 @@ export const FeedItemGrantWithApplicants: FC = const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const [expanded, setExpanded] = useState(false); + const [activeSection, setActiveSection] = useState<'proposals' | 'invited'>('proposals'); + const [invitedTotal, setInvitedTotal] = useState(null); const isFunderDashboard = useIsFunderDashboard(); + const { user } = useUser(); // On the funder dashboard the row is read-only (insights instead of Apply CTA). const showKeyInsights = isFunderDashboard; const showApplyFooter = !isFunderDashboard; @@ -207,6 +235,17 @@ export const FeedItemGrantWithApplicants: FC = const remaining = allProposals.length - VISIBLE_PROPOSALS; const hasProposals = allProposals.length > 0; + const unifiedDocumentId = content.unifiedDocumentId + ? Number(content.unifiedDocumentId) + : undefined; + const canViewInvitedExperts = canViewGrantInvitedExperts(user, grant.createdBy); + const showInvitedExperts = + isFunderDashboard && + canViewInvitedExperts && + unifiedDocumentId != null && + !Number.isNaN(unifiedDocumentId); + const showSectionTabs = hasProposals && showInvitedExperts; + const isClosed = grant.status === 'CLOSED' || grant.isExpired || !grant.isActive; const totalRequested = allProposals.reduce((sum, a) => { @@ -304,58 +343,99 @@ export const FeedItemGrantWithApplicants: FC = {/* Proposal rows */} {hasProposals && ( <> -
- - Applicant Proposals - -
-
- {shown.map((application, i) => ( - - ))} - {!expanded && remaining > 0 && ( - - )} - {expanded && remaining > 0 && ( - - )} -
+ {showSectionTabs ? ( + setActiveSection(value as 'proposals' | 'invited')} + options={[ + { + value: 'proposals', + label: `Applicant Proposals (${allProposals.length})`, + }, + { + value: 'invited', + label: `Invited Experts${invitedTotal != null ? ` (${invitedTotal})` : ''}`, + }, + ]} + /> + ) : ( +
+ + Applicant Proposals + +
+ )} + + {(!showSectionTabs || activeSection === 'proposals') && ( +
+ {shown.map((application, i) => ( + + ))} + {!expanded && remaining > 0 && ( + + )} + {expanded && remaining > 0 && ( + + )} +
+ )} + + {showInvitedExperts && ( + + )} )} - {/* No proposals — experts invited state */} + {/* No proposals — placeholder; funder dashboard gets expand control below */} {!hasProposals && !isClosed && ( -
-
- - - Experts have been invited to apply - +
+
+
+ + + Experts have been invited to apply + +
+

+ Anyone can apply. Be the first to submit yours. +

-

- Anyone can apply. be the first to submit yours. -

+ {showInvitedExperts && ( + + )}
)} diff --git a/components/Funding/GrantInvitedExpertCard.tsx b/components/Funding/GrantInvitedExpertCard.tsx new file mode 100644 index 000000000..bbf8956a5 --- /dev/null +++ b/components/Funding/GrantInvitedExpertCard.tsx @@ -0,0 +1,110 @@ +'use client'; + +import Link from 'next/link'; +import { Building2, ExternalLink } from 'lucide-react'; +import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { cn } from '@/utils/styles'; +import type { GrantInvitedExpert } from '@/types/expertFinder'; + +interface GrantInvitedExpertCardProps { + expert: GrantInvitedExpert; + className?: string; +} + +function ExpertSourceLinks({ expert }: { expert: GrantInvitedExpert }) { + const sources = expert.sources?.length ? expert.sources : []; + if (sources.length === 0) return null; + + return ( + + {sources.map((src, i) => ( + + e.stopPropagation()} + > + + + + ))} + + ); +} + +export function GrantInvitedExpertCard({ expert, className }: GrantInvitedExpertCardProps) { + const name = expert.displayName || expert.name; + const title = expert.title?.trim() || ''; + const university = expert.affiliation?.trim() || ''; + const authorId = expert.registeredUser?.author?.id; + const hasAuthor = authorId != null && authorId > 0; + + const nameLink = hasAuthor ? ( + e.stopPropagation()} + > + {name} + + ) : ( + {name} + ); + + const profileBlock = hasAuthor ? ( + +
+ {nameLink} + {title ? ( +

+ {title} +

+ ) : null} +
+
+ ) : ( +
+ {nameLink} + {title ? ( +

+ {title} +

+ ) : null} +
+ ); + + return ( +
+
+
{profileBlock}
+ +
+ {university ? ( +
+ +

+ {university} +

+
+ ) : null} +
+ ); +} diff --git a/components/Funding/GrantInvitedExpertsGrid.tsx b/components/Funding/GrantInvitedExpertsGrid.tsx new file mode 100644 index 000000000..3b7b33cdd --- /dev/null +++ b/components/Funding/GrantInvitedExpertsGrid.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { FC } from 'react'; +import { cn } from '@/utils/styles'; +import { PaginationButton } from '@/components/ui/PaginationButton'; +import { ExpertFinderService } from '@/services/expertFinder.service'; +import type { GrantInvitedExpert } from '@/types/expertFinder'; +import { GrantInvitedExpertCard } from './GrantInvitedExpertCard'; + +export const GRANT_INVITED_EXPERTS_GRID_CLASS = + 'grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4'; + +interface GrantInvitedExpertsGridProps { + experts: GrantInvitedExpert[]; + className?: string; +} + +export const GrantInvitedExpertsGrid: FC = ({ + experts, + className, +}) => { + if (experts.length === 0) { + return null; + } + + return ( +
+
+ {experts.map((expert, i) => ( + + ))} +
+
+ ); +}; + +export function GrantInvitedExpertsGridSkeleton({ count }: { count?: number }) { + const itemCount = count ?? ExpertFinderService.GRANT_INVITED_EXPERTS_PAGE_SIZE; + + return ( +
+ {Array.from({ length: itemCount }, (_, i) => ( +
+ ))} +
+ ); +} + +interface GrantInvitedExpertsPaginationProps { + page: number; + totalPages: number; + total: number; + isLoading: boolean; + onPageChange: (page: number) => void; +} + +export function GrantInvitedExpertsPagination({ + page, + totalPages, + total, + isLoading, + onPageChange, +}: GrantInvitedExpertsPaginationProps) { + if (totalPages <= 1) return null; + + const hasPrevPage = page > 1; + const hasNextPage = page < totalPages; + + return ( +
+
+ Page {page} of {totalPages} + {total > 0 && ({total} total)} +
+
+ onPageChange(page - 1)} + disabled={!hasPrevPage || isLoading} + isLoading={isLoading} + /> + onPageChange(page + 1)} + disabled={!hasNextPage || isLoading} + isLoading={isLoading} + /> +
+
+ ); +} diff --git a/components/Funding/GrantInvitedExpertsSection.tsx b/components/Funding/GrantInvitedExpertsSection.tsx new file mode 100644 index 000000000..63e7c58d6 --- /dev/null +++ b/components/Funding/GrantInvitedExpertsSection.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/utils/styles'; +import { useGrantInvitedExperts } from '@/hooks/useExpertFinder'; +import { + GrantInvitedExpertsGridSkeleton, + GrantInvitedExpertsGrid, + GrantInvitedExpertsPagination, +} from './GrantInvitedExpertsGrid'; + +interface GrantInvitedExpertsSectionProps { + unifiedDocumentId: number; + canView: boolean; + variant: 'standalone' | 'tab-panel'; + isActive?: boolean; + onTotalChange?: (total: number | null) => void; + className?: string; +} + +export const GrantInvitedExpertsSection: FC = ({ + unifiedDocumentId, + canView, + variant, + isActive = false, + onTotalChange, + className, +}) => { + const [standaloneOpened, setStandaloneOpened] = useState(false); + const { + experts, + total, + page, + totalPages, + isLoading, + error, + isForbidden, + hasLoaded, + load, + goToPage, + reset, + } = useGrantInvitedExperts(); + + useEffect(() => { + return () => reset(); + }, [reset, unifiedDocumentId]); + + useEffect(() => { + if (!canView) return; + + if (variant === 'tab-panel' && isActive && !hasLoaded && !isLoading && !isForbidden) { + void load(unifiedDocumentId); + } + }, [canView, variant, isActive, hasLoaded, isLoading, isForbidden, load, unifiedDocumentId]); + + useEffect(() => { + onTotalChange?.(hasLoaded ? total : null); + }, [hasLoaded, total, onTotalChange]); + + if (!canView || isForbidden) { + return null; + } + + const handleStandaloneToggle = () => { + if (standaloneOpened) { + setStandaloneOpened(false); + return; + } + setStandaloneOpened(true); + if (!hasLoaded && !isLoading) { + void load(unifiedDocumentId); + } + }; + + const standaloneToggleButton = ( + + ); + + const handleRetry = () => { + void load(unifiedDocumentId); + }; + + const panelContent = ( + <> + {isLoading && !hasLoaded ? ( + + ) : error ? ( +
+

Couldn't load invited experts

+ +
+ ) : hasLoaded && experts.length === 0 ? ( +
+

No invited experts yet

+
+ ) : hasLoaded && experts.length > 0 ? ( + <> + {isLoading ? ( + + ) : ( + + )} + void goToPage(nextPage)} + /> + + ) : null} + + ); + + if (variant === 'tab-panel') { + if (!isActive) return null; + return
{panelContent}
; + } + + return ( +
+ {standaloneToggleButton} + {standaloneOpened && panelContent} +
+ ); +}; diff --git a/components/ui/ButtonGroup.tsx b/components/ui/ButtonGroup.tsx index cb4ba8240..bd50d3ae4 100644 --- a/components/ui/ButtonGroup.tsx +++ b/components/ui/ButtonGroup.tsx @@ -15,7 +15,7 @@ interface ButtonGroupProps { onChange: (value: string) => void; className?: string; size?: 'sm' | 'md' | 'lg'; - variant?: 'default' | 'outlined' | 'pill'; + variant?: 'default' | 'outlined' | 'pill' | 'section'; } export function ButtonGroup({ @@ -44,6 +44,8 @@ export function ButtonGroup({ return 'flex gap-0 border border-gray-300 bg-white rounded-lg'; case 'pill': return 'flex gap-2 p-1 border border-gray-300 bg-white rounded-lg'; + case 'section': + return 'flex border-b border-gray-100 bg-gray-50/80'; default: return cn('flex gap-1 bg-gray-100 rounded-xl', containerPadding[size]); } @@ -65,6 +67,14 @@ export function ButtonGroup({ isActive ? 'bg-gray-200 text-gray-900' : 'text-gray-600 hover:bg-gray-100' ); + case 'section': + return cn( + 'px-5 py-2 text-[10px] font-bold uppercase tracking-wider transition-colors cursor-pointer', + isActive + ? 'text-gray-900 border-b-2 border-gray-900 -mb-px' + : 'text-gray-500 hover:text-gray-700' + ); + default: return cn( 'font-medium rounded-lg transition-all flex items-center gap-2 whitespace-nowrap', @@ -80,6 +90,8 @@ export function ButtonGroup({ return 'bg-gray-200 text-gray-600 rounded font-medium'; case 'pill': return 'bg-gray-300 text-gray-700 rounded font-medium'; + case 'section': + return 'bg-gray-200 text-gray-600 rounded font-medium'; default: return 'bg-gray-200 text-gray-700 rounded font-medium'; } @@ -90,6 +102,7 @@ export function ButtonGroup({ {options.map((option) => (