Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
outreachDocumentLabel,
} from '@/types/expertFinder';
import { ExpertFormModal } from './ExpertFormModal';
import { ExpertSourceLinkIcon } from './ExpertSourceLinkIcon';
import { ExpertSourceLinkIcon } from '@/components/ExpertFinder/ExpertSourceLinkIcon';

interface ExpertResultCardProps {
expert: ExpertResult;
Expand Down
169 changes: 123 additions & 46 deletions components/Feed/items/FeedItemGrantComprehensive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import Link from 'next/link';
import { FeedEntry, FeedGrantContent } from '@/types/feed';
import { Avatar } from '@/components/ui/Avatar';
import { ButtonGroup } from '@/components/ui/ButtonGroup';
import { CalendarOff, Star } from 'lucide-react';
import { cn } from '@/utils/styles';
import { RadiatingDot } from '@/components/ui/RadiatingDot';
Expand All @@ -17,6 +18,10 @@
import { KeyInsightsModal } from '@/components/modals/KeyInsightsModal';
import { KeyInsightsLine } from '@/components/work/KeyInsights/KeyInsightsLine';
import { KeyInsightsPanel } from '@/components/work/KeyInsights/KeyInsightsPanel';
import { GrantInvitedExpertsSection } from '@/components/Funding/GrantInvitedExpertsSection';
import { useUser } from '@/contexts/UserContext';
import type { AuthorProfile } from '@/types/authorProfile';
import type { User } from '@/types/user';
import { formatCompact } from './FeedItemGrantWithApplicants';

interface FeedItemGrantComprehensiveProps {
Expand All @@ -26,6 +31,26 @@

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;
}

interface GrantDeploymentProgressProps {
/** Funder's commitment ceiling — the bar is "full" when deployed reaches this. */
availableFunding: number;
Expand Down Expand Up @@ -457,7 +482,10 @@
}) => {
const { showUSD } = useCurrencyPreference();
const { exchangeRate } = useExchangeRate();
const { user } = useUser();
const [expanded, setExpanded] = useState(false);
const [activeSection, setActiveSection] = useState<'proposals' | 'invited'>('proposals');
const [invitedTotal, setInvitedTotal] = useState<number | null>(null);

const content = entry.content as FeedGrantContent;
const grant = content.grant;
Expand All @@ -481,6 +509,14 @@
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 =
canViewInvitedExperts && unifiedDocumentId != null && !Number.isNaN(unifiedDocumentId);
const showSectionTabs = hasProposals && showInvitedExperts;

const isClosed = grant.status === 'CLOSED' || grant.isExpired || !grant.isActive;

// Walk every applicant's contributor list and bucket each contribution into
Expand Down Expand Up @@ -544,57 +580,98 @@
{/* Proposal rows */}
{hasProposals && (
<>
<div className="px-5 py-2 border-b border-gray-100 bg-gray-50/80">
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
Applicant Proposals
</span>
</div>
<div>
{shown.map((application, i) => (
<ProposalRow
key={`${application.profile.id}-${application.fundraise!.id}-${i}`}
application={application}
showUSD={showUSD}
exchangeRate={exchangeRate}
isLast={
i === shown.length - 1 && (expanded || allProposals.length <= VISIBLE_PROPOSALS)
}
/>
))}
{!expanded && remaining > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full px-5 py-2.5 text-center text-xs font-semibold text-blue-500 hover:bg-gray-50/80 transition-colors border-t border-gray-100 cursor-pointer"
>
Show {remaining} more proposal{remaining > 1 ? 's' : ''}
</button>
)}
{expanded && remaining > 0 && (
<button
type="button"
onClick={() => setExpanded(false)}
className="w-full px-5 py-2.5 text-center text-xs font-semibold text-gray-400 hover:bg-gray-50/80 transition-colors border-t border-gray-100 cursor-pointer"
>
Show less
</button>
)}
</div>
{showSectionTabs ? (
<ButtonGroup
variant="section"
value={activeSection}
onChange={(value) => setActiveSection(value as 'proposals' | 'invited')}
options={[
{
value: 'proposals',
label: `Applicant Proposals (${allProposals.length})`,
},
{
value: 'invited',
label: `Invited Experts${invitedTotal != null ? ` (${invitedTotal})` : ''}`,

Check warning on line 595 in components/Feed/items/FeedItemGrantComprehensive.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ9W1ZwJemFbw1K7s3_J&open=AZ9W1ZwJemFbw1K7s3_J&pullRequest=900
},
]}
/>
) : (
<div className="px-5 py-2 border-b border-gray-100 bg-gray-50/80">
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
Applicant Proposals
</span>
</div>
)}

{(!showSectionTabs || activeSection === 'proposals') && (
<div>
{shown.map((application, i) => (
<ProposalRow
key={`${application.profile.id}-${application.fundraise!.id}-${i}`}
application={application}
showUSD={showUSD}
exchangeRate={exchangeRate}
isLast={
i === shown.length - 1 && (expanded || allProposals.length <= VISIBLE_PROPOSALS)
}
/>
))}
{!expanded && remaining > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full px-5 py-2.5 text-center text-xs font-semibold text-blue-500 hover:bg-gray-50/80 transition-colors border-t border-gray-100 cursor-pointer"
>
Show {remaining} more proposal{remaining > 1 ? 's' : ''}
</button>
)}
{expanded && remaining > 0 && (
<button
type="button"
onClick={() => setExpanded(false)}
className="w-full px-5 py-2.5 text-center text-xs font-semibold text-gray-400 hover:bg-gray-50/80 transition-colors border-t border-gray-100 cursor-pointer"
>
Show less
</button>
)}
</div>
)}

{showInvitedExperts && (
<GrantInvitedExpertsSection
unifiedDocumentId={unifiedDocumentId!}
canView={canViewInvitedExperts}
variant="tab-panel"
isActive={activeSection === 'invited'}
onTotalChange={setInvitedTotal}
/>
)}
</>
)}

{/* No proposals — experts invited state */}
{/* No proposals — placeholder; funder dashboard gets expand control below */}
{!hasProposals && !isClosed && (
<div className="px-5 py-4 border-b border-gray-100 bg-gray-50/50">
<div className="flex items-center justify-center gap-2 mb-1.5">
<RadiatingDot color="bg-emerald-500" size="sm" />
<span className="text-[11px] font-semibold text-gray-700">
Experts have been invited to apply
</span>
<div className="border-b border-gray-100 bg-gray-50/50">
<div className="px-5 py-4">
<div className="flex items-center justify-center gap-2 mb-1.5">
<RadiatingDot color="bg-emerald-500" size="sm" />
<span className="text-[11px] font-semibold text-gray-700">
Experts have been invited to apply
</span>
</div>
<p className="text-[11px] text-gray-600 text-center">
Anyone can apply. Be the first to submit yours.
</p>
</div>
<p className="text-[11px] text-gray-600 text-center">
Anyone can apply. be the first to submit yours.
</p>
{showInvitedExperts && (
<GrantInvitedExpertsSection
unifiedDocumentId={unifiedDocumentId!}
canView={canViewInvitedExperts}
variant="standalone"
onTotalChange={setInvitedTotal}
/>
)}
</div>
)}
</div>
Expand Down
113 changes: 113 additions & 0 deletions components/Funding/GrantInvitedExpertCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';

import Link from 'next/link';
import { Building2 } from 'lucide-react';
import { AuthorTooltip } from '@/components/ui/AuthorTooltip';
import { ExpertSourceLinkIcon } from '@/components/ExpertFinder/ExpertSourceLinkIcon';
import { Tooltip } from '@/components/ui/Tooltip';
import { cn } from '@/utils/styles';
import type { ExpertSourceLink, GrantInvitedExpert } from '@/types/expertFinder';

interface GrantInvitedExpertCardProps {
expert: GrantInvitedExpert;
className?: string;
}

function ExpertSourceLinkItem({ src }: { src: ExpertSourceLink }) {

Check warning on line 16 in components/Funding/GrantInvitedExpertCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ9W5T6vb3Yg5Wvl5rTd&open=AZ9W5T6vb3Yg5Wvl5rTd&pullRequest=900
return (
<Tooltip
content={src.text}
position="top"
width="w-72"
className="text-left"
wrapperAs="span"
wrapperClassName="inline-flex shrink-0 items-center"
>
<a
href={src.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex rounded p-0.5 text-gray-600 transition-colors hover:text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1"
aria-label={src.text}
title={src.text}
onClick={(e) => e.stopPropagation()}
>
<ExpertSourceLinkIcon url={src.url} text={src.text} />
</a>
</Tooltip>
);
}

export function GrantInvitedExpertCard({ expert, className }: GrantInvitedExpertCardProps) {

Check warning on line 41 in components/Funding/GrantInvitedExpertCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ9W1ZzEemFbw1K7s3_L&open=AZ9W1ZzEemFbw1K7s3_L&pullRequest=900
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 sources = expert.sources?.length ? expert.sources : [];

const nameLink = hasAuthor ? (
<Link
href={`/author/${authorId}`}
className="text-[11px] font-semibold text-blue-500 underline truncate hover:text-blue-600 leading-snug"
onClick={(e) => e.stopPropagation()}
>
{name}
</Link>
) : (
<span className="text-[11px] font-semibold text-gray-900 truncate leading-snug">{name}</span>
);

const profileBlock = hasAuthor ? (
<AuthorTooltip authorId={authorId} placement="top">
<div className="min-w-0 cursor-pointer">
{nameLink}
{title ? (
<p className="mt-0.5 line-clamp-1 text-[10px] leading-snug text-gray-500" title={title}>
{title}
</p>
) : null}
</div>
</AuthorTooltip>
) : (
<div className="min-w-0">
{nameLink}
{title ? (
<p className="mt-0.5 line-clamp-1 text-[10px] leading-snug text-gray-500" title={title}>
{title}
</p>
) : null}
</div>
);

return (
<div
className={cn(
'flex min-h-[80px] flex-col rounded-md border px-2.5 py-2',
hasAuthor ? 'border-emerald-200 bg-emerald-50/70' : 'border-gray-100 bg-white',
className
)}
>
<div className="min-w-0">
<div>
{sources.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{sources.map((src, i) => (
<ExpertSourceLinkItem key={`${src.url}-${i}`} src={src} />
))}
</div>
)}
</div>
<div className="min-w-0 flex-1">{profileBlock}</div>
</div>
{university ? (
<div className="mt-1.5 flex min-w-0 items-start gap-1 text-[10px] leading-snug text-gray-500">
<Building2 className="mt-px h-3 w-3 shrink-0 text-gray-400" aria-hidden />
<p className="line-clamp-2 min-w-0" title={university}>
{university}
</p>
</div>
) : null}
</div>
);
}
Loading