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
174 changes: 127 additions & 47 deletions components/Feed/items/FeedItemGrantWithApplicants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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';
Expand All @@ -20,6 +21,10 @@
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;
Expand All @@ -28,6 +33,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;
}

function formatCompact(amount: number, showUSD: boolean, exchangeRate: number): string {
return formatCurrency({ amount, showUSD, exchangeRate, skipConversion: true, shorten: true });
}
Expand Down Expand Up @@ -180,7 +205,10 @@
const { showUSD } = useCurrencyPreference();
const { exchangeRate } = useExchangeRate();
const [expanded, setExpanded] = useState(false);
const [activeSection, setActiveSection] = useState<'proposals' | 'invited'>('proposals');
const [invitedTotal, setInvitedTotal] = useState<number | null>(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;
Expand All @@ -207,6 +235,17 @@
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) => {
Expand Down Expand Up @@ -304,58 +343,99 @@
{/* 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)
}
showKeyInsights={showKeyInsights}
/>
))}
{!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')}

Check warning on line 350 in components/Feed/items/FeedItemGrantWithApplicants.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ6pAB5AtGFzdPmCSiSJ&open=AZ6pAB5AtGFzdPmCSiSJ&pullRequest=900
options={[
{
value: 'proposals',
label: `Applicant Proposals (${allProposals.length})`,
},
{
value: 'invited',
label: `Invited Experts${invitedTotal != null ? ` (${invitedTotal})` : ''}`,

Check warning on line 358 in components/Feed/items/FeedItemGrantWithApplicants.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ6pAB5AtGFzdPmCSiSK&open=AZ6pAB5AtGFzdPmCSiSK&pullRequest=900

Check warning on line 358 in components/Feed/items/FeedItemGrantWithApplicants.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=AZ6pAB5AtGFzdPmCSiSL&open=AZ6pAB5AtGFzdPmCSiSL&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)
}
showKeyInsights={showKeyInsights}
/>
))}
{!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!}

Check warning on line 407 in components/Feed/items/FeedItemGrantWithApplicants.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ6o-HeGx20R8avXMjWx&open=AZ6o-HeGx20R8avXMjWx&pullRequest=900
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!}

Check warning on line 433 in components/Feed/items/FeedItemGrantWithApplicants.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ6o-HeGx20R8avXMjWy&open=AZ6o-HeGx20R8avXMjWy&pullRequest=900
canView={canViewInvitedExperts}
variant="standalone"
onTotalChange={setInvitedTotal}
/>
)}
</div>
)}

Expand Down
110 changes: 110 additions & 0 deletions components/Funding/GrantInvitedExpertCard.tsx
Original file line number Diff line number Diff line change
@@ -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 }) {

Check warning on line 15 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=AZ6o-Hd0x20R8avXMjWt&open=AZ6o-Hd0x20R8avXMjWt&pullRequest=900
const sources = expert.sources?.length ? expert.sources : [];
if (sources.length === 0) return null;

return (
<span className="inline-flex shrink-0 items-center gap-0.5">
{sources.map((src, i) => (
<Tooltip
key={`${src.url}-${i}`}
content={src.text}
position="top"
width="w-72"
className="text-left"
wrapperClassName="inline-flex shrink-0 items-center"
>
<a
href={src.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex rounded p-0.5 text-primary-600 transition-colors hover:text-primary-700"
aria-label={src.text}
title={src.text}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
</a>
</Tooltip>
))}
</span>
);
}

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

Check warning on line 47 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=AZ6o-Hd0x20R8avXMjWu&open=AZ6o-Hd0x20R8avXMjWu&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 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="flex min-w-0 items-start justify-between gap-1">
<div className="min-w-0 flex-1">{profileBlock}</div>
<ExpertSourceLinks expert={expert} />
</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