From c4dbae38921aa2cb2a99dd386334def5d18c1910 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Mon, 8 Jun 2026 23:38:42 +0300 Subject: [PATCH 1/2] Show invited experts on funder dashboard grant cards --- .../items/FeedItemGrantWithApplicants.tsx | 169 ++++++++++++++---- .../Funding/GrantInvitedExpertsSection.tsx | 145 +++++++++++++++ components/Funding/InvitedExpertRow.tsx | 110 ++++++++++++ components/Funding/InvitedExpertsPanel.tsx | 90 ++++++++++ hooks/useExpertFinder.ts | 137 +++++++++----- services/expertFinder.service.ts | 43 ++++- types/expertFinder.ts | 38 ++++ 7 files changed, 638 insertions(+), 94 deletions(-) create mode 100644 components/Funding/GrantInvitedExpertsSection.tsx create mode 100644 components/Funding/InvitedExpertRow.tsx create mode 100644 components/Funding/InvitedExpertsPanel.tsx diff --git a/components/Feed/items/FeedItemGrantWithApplicants.tsx b/components/Feed/items/FeedItemGrantWithApplicants.tsx index 7677c442b..dc4152bc5 100644 --- a/components/Feed/items/FeedItemGrantWithApplicants.tsx +++ b/components/Feed/items/FeedItemGrantWithApplicants.tsx @@ -20,6 +20,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 +32,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 +204,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 +234,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 +342,111 @@ export const FeedItemGrantWithApplicants: FC = {/* Proposal rows */} {hasProposals && ( <> -
- - Applicant Proposals - -
-
- {shown.map((application, i) => ( - - ))} - {!expanded && remaining > 0 && ( + {showSectionTabs ? ( +
- )} - {expanded && remaining > 0 && ( - )} -
+
+ ) : ( +
+ + 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/GrantInvitedExpertsSection.tsx b/components/Funding/GrantInvitedExpertsSection.tsx new file mode 100644 index 000000000..6e214a946 --- /dev/null +++ b/components/Funding/GrantInvitedExpertsSection.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/utils/styles'; +import { useGrantInvitedExperts } from '@/hooks/useExpertFinder'; +import { + InvitedExpertsGridSkeleton, + InvitedExpertsPanel, + InvitedExpertsPagination, +} from './InvitedExpertsPanel'; + +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/Funding/InvitedExpertRow.tsx b/components/Funding/InvitedExpertRow.tsx new file mode 100644 index 000000000..253ed55d6 --- /dev/null +++ b/components/Funding/InvitedExpertRow.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 InvitedExpertRowProps { + 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 InvitedExpertRow({ expert, className }: InvitedExpertRowProps) { + 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/InvitedExpertsPanel.tsx b/components/Funding/InvitedExpertsPanel.tsx new file mode 100644 index 000000000..4859a59d4 --- /dev/null +++ b/components/Funding/InvitedExpertsPanel.tsx @@ -0,0 +1,90 @@ +'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 { InvitedExpertRow } from './InvitedExpertRow'; + +export const INVITED_EXPERTS_GRID_CLASS = 'grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4'; + +interface InvitedExpertsPanelProps { + experts: GrantInvitedExpert[]; + className?: string; +} + +export const InvitedExpertsPanel: FC = ({ experts, className }) => { + if (experts.length === 0) { + return null; + } + + return ( +
+
+ {experts.map((expert, i) => ( + + ))} +
+
+ ); +}; + +export function InvitedExpertsGridSkeleton({ count }: { count?: number }) { + const itemCount = count ?? ExpertFinderService.GRANT_INVITED_EXPERTS_PAGE_SIZE; + + return ( +
+ {Array.from({ length: itemCount }, (_, i) => ( +
+ ))} +
+ ); +} + +interface InvitedExpertsPaginationProps { + page: number; + totalPages: number; + total: number; + isLoading: boolean; + onPageChange: (page: number) => void; +} + +export function InvitedExpertsPagination({ + page, + totalPages, + total, + isLoading, + onPageChange, +}: InvitedExpertsPaginationProps) { + 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/hooks/useExpertFinder.ts b/hooks/useExpertFinder.ts index 144a34cc2..5ee330c7a 100644 --- a/hooks/useExpertFinder.ts +++ b/hooks/useExpertFinder.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { EXPERT_FINDER_LIST_PAGE_SIZE } from '@/app/expert-finder/lib/paginationParams'; import { ExpertFinderService, @@ -12,13 +12,14 @@ import { type UpdateSavedTemplatePayload, } from '@/services/expertFinder.service'; import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; +import { ApiError } from '@/services/types/api'; import type { ExpertResult, ExpertSearchCreated, - InvitedExperts, ExpertSearchResult, ExpertSearchListItem, GeneratedEmail, + GrantInvitedExpert, SavedTemplate, } from '@/types/expertFinder'; import type { Work } from '@/types/work'; @@ -307,71 +308,115 @@ export function useWorkByUnifiedDocumentId( return [{ work, isLoading, error }, fetch]; } -// ── useDocumentInvited ───────────────────────────────────────────────────── +// ── useGrantInvitedExperts ─────────────────────────────────────────────────── -export interface UseDocumentInvitedReturn { - data: InvitedExperts | null; +export interface UseGrantInvitedExpertsReturn { + experts: GrantInvitedExpert[]; + total: number; + page: number; + totalPages: number; isLoading: boolean; error: Error | null; - refresh: () => void; + isForbidden: boolean; + hasLoaded: boolean; + load: (unifiedDocumentId: number) => Promise; + goToPage: (page: number) => Promise; + reset: () => void; } -/** - * Fetches invited experts for a unified document. - */ -export function useDocumentInvited( - unifiedDocumentId: number | null | undefined -): UseDocumentInvitedReturn { - const [data, setData] = useState(null); +export function useGrantInvitedExperts(): UseGrantInvitedExpertsReturn { + const [experts, setExperts] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [isForbidden, setIsForbidden] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const unifiedDocumentIdRef = useRef(null); + + const pageSize = ExpertFinderService.GRANT_INVITED_EXPERTS_PAGE_SIZE; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const reset = useCallback(() => { + unifiedDocumentIdRef.current = null; + setExperts([]); + setTotal(0); + setPage(1); + setIsLoading(false); + setError(null); + setIsForbidden(false); + setHasLoaded(false); + }, []); - const load = useCallback( - async (signal?: { cancelled: boolean }) => { - if (unifiedDocumentId == null) return; - + const fetchPage = useCallback( + async (unifiedDocumentId: number, pageNum: number) => { + unifiedDocumentIdRef.current = unifiedDocumentId; setIsLoading(true); setError(null); try { - const result = await ExpertFinderService.getDocumentInvited(unifiedDocumentId); - if (signal?.cancelled) return; - setData(result); + const result = await ExpertFinderService.listInvitedExperts({ + unifiedDocumentId, + offset: (pageNum - 1) * pageSize, + }); + + if (unifiedDocumentIdRef.current !== unifiedDocumentId) return; + + setExperts(result.items); + setTotal(result.total); + setPage(pageNum); + setHasLoaded(true); } catch (err) { - if (signal?.cancelled) return; + if (unifiedDocumentIdRef.current !== unifiedDocumentId) return; + + if (err instanceof ApiError && err.status === 403) { + setIsForbidden(true); + setExperts([]); + setTotal(0); + setHasLoaded(true); + return; + } + setError(err instanceof Error ? err : new Error('Failed to load invited experts')); - setData(null); } finally { - if (!signal?.cancelled) setIsLoading(false); + if (unifiedDocumentIdRef.current === unifiedDocumentId) { + setIsLoading(false); + } } }, - [unifiedDocumentId] + [pageSize] ); - useEffect(() => { - if (unifiedDocumentId == null) { - setData(null); - setError(null); - setIsLoading(false); - return; - } - - const signal = { cancelled: false }; - load(signal); - return () => { - signal.cancelled = true; - }; - }, [unifiedDocumentId, load]); - - const refresh = useCallback(() => { - if (unifiedDocumentId != null) load(); - }, [unifiedDocumentId, load]); + const load = useCallback( + async (unifiedDocumentId: number) => { + setIsForbidden(false); + await fetchPage(unifiedDocumentId, 1); + }, + [fetchPage] + ); - if (unifiedDocumentId == null) { - return { data: null, isLoading: false, error: null, refresh: () => {} }; - } + const goToPage = useCallback( + async (pageNum: number) => { + const unifiedDocumentId = unifiedDocumentIdRef.current; + if (unifiedDocumentId == null || pageNum < 1) return; + await fetchPage(unifiedDocumentId, pageNum); + }, + [fetchPage] + ); - return { data, isLoading, error, refresh }; + return { + experts, + total, + page, + totalPages, + isLoading, + error, + isForbidden, + hasLoaded, + load, + goToPage, + reset, + }; } // ── useGeneratedEmails ────────────────────────────────────────────────────── diff --git a/services/expertFinder.service.ts b/services/expertFinder.service.ts index 2324e9181..46c778535 100644 --- a/services/expertFinder.service.ts +++ b/services/expertFinder.service.ts @@ -9,8 +9,8 @@ import { transformExpertSearchListItem, transformGeneratedEmail, transformSavedTemplate, - transformInvitedExperts, - type InvitedExperts, + transformGrantInvitedExpert, + type GrantInvitedExpertsList, type ExpertResult, type ExpertSearchCreated, type ExpertSearchResult, @@ -184,6 +184,8 @@ export interface UpdateSavedTemplatePayload { export class ExpertFinderService { private static readonly BASE_PATH = '/api/research_ai/expert-finder'; + static readonly GRANT_INVITED_EXPERTS_PAGE_SIZE = 20; + /** * Submit a new expert search. * POST /api/research_ai/expert-finder/searches/ @@ -303,14 +305,37 @@ export class ExpertFinderService { } /** - * Fetch invited experts for a unified document. - * GET /api/research_ai/expert-finder/documents/:unifiedDocumentId/invited/ + * List invited experts for a grant/RFP unified document. + * GET /api/research_ai/expert-finder/experts/ */ - static async getDocumentInvited(unifiedDocumentId: number): Promise { - const raw = await ApiClient.get>( - `${this.BASE_PATH}/documents/${unifiedDocumentId}/invited/` - ); - return transformInvitedExperts(raw); + static async listInvitedExperts(params: { + unifiedDocumentId: number; + limit?: number; + offset?: number; + }): Promise { + const limit = params.limit ?? this.GRANT_INVITED_EXPERTS_PAGE_SIZE; + const offset = params.offset ?? 0; + const searchParams = new URLSearchParams({ + unified_document_id: String(params.unifiedDocumentId), + limit: String(limit), + offset: String(offset), + }); + + const response = await ApiClient.get<{ + items: Record[]; + total: number; + limit: number; + offset: number; + }>(`${this.BASE_PATH}/experts/?${searchParams.toString()}`); + + return { + items: Array.isArray(response.items) + ? response.items.map((item) => transformGrantInvitedExpert(item)) + : [], + total: response.total ?? 0, + limit: response.limit ?? limit, + offset: response.offset ?? offset, + }; } // ── Generated emails ───────────────────────────────────────────────────── diff --git a/types/expertFinder.ts b/types/expertFinder.ts index 0aaa6b13c..616adf2c2 100644 --- a/types/expertFinder.ts +++ b/types/expertFinder.ts @@ -369,6 +369,44 @@ export const transformInvitedExperts = createTransformer((r totalCount: raw.total_count ?? 0, })); +// ── Grant invited experts (experts list API) ────────────────────────────────── + +export interface RegisteredExpertUser { + userId: number; + author: AuthorProfile | null; +} + +export interface GrantInvitedExpert extends ExpertResult { + displayName: string; + registeredUser: RegisteredExpertUser | null; +} + +export interface GrantInvitedExpertsList { + items: GrantInvitedExpert[]; + total: number; + limit: number; + offset: number; +} + +export const transformGrantInvitedExpert = createTransformer((raw) => { + const base = transformExpertResult(raw); + const displayName = String(raw.display_name ?? '').trim() || base.name || 'Unknown'; + + const registeredRaw = raw.registered_user; + const registeredUser: RegisteredExpertUser | null = registeredRaw + ? { + userId: registeredRaw.user_id ?? 0, + author: registeredRaw.author ? transformAuthorProfile(registeredRaw.author) : null, + } + : null; + + return { + ...base, + displayName, + registeredUser, + }; +}); + // ── Saved templates (app-level, camelCase) ─────────────────────────────────── export interface SavedTemplate { From 377adbe15a83a349bd45d93ac68dad5d4ae3bf1d Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Mon, 8 Jun 2026 23:49:35 +0300 Subject: [PATCH 2/2] small rfactoring --- .../items/FeedItemGrantWithApplicants.tsx | 43 +++++++------------ ...pertRow.tsx => GrantInvitedExpertCard.tsx} | 4 +- ...sPanel.tsx => GrantInvitedExpertsGrid.tsx} | 26 ++++++----- .../Funding/GrantInvitedExpertsSection.tsx | 18 +++++--- components/ui/ButtonGroup.tsx | 15 ++++++- 5 files changed, 58 insertions(+), 48 deletions(-) rename components/Funding/{InvitedExpertRow.tsx => GrantInvitedExpertCard.tsx} (96%) rename components/Funding/{InvitedExpertsPanel.tsx => GrantInvitedExpertsGrid.tsx} (70%) diff --git a/components/Feed/items/FeedItemGrantWithApplicants.tsx b/components/Feed/items/FeedItemGrantWithApplicants.tsx index dc4152bc5..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'; @@ -343,33 +344,21 @@ export const FeedItemGrantWithApplicants: FC = {hasProposals && ( <> {showSectionTabs ? ( -
- - -
+ setActiveSection(value as 'proposals' | 'invited')} + options={[ + { + value: 'proposals', + label: `Applicant Proposals (${allProposals.length})`, + }, + { + value: 'invited', + label: `Invited Experts${invitedTotal != null ? ` (${invitedTotal})` : ''}`, + }, + ]} + /> ) : (
diff --git a/components/Funding/InvitedExpertRow.tsx b/components/Funding/GrantInvitedExpertCard.tsx similarity index 96% rename from components/Funding/InvitedExpertRow.tsx rename to components/Funding/GrantInvitedExpertCard.tsx index 253ed55d6..bbf8956a5 100644 --- a/components/Funding/InvitedExpertRow.tsx +++ b/components/Funding/GrantInvitedExpertCard.tsx @@ -7,7 +7,7 @@ import { Tooltip } from '@/components/ui/Tooltip'; import { cn } from '@/utils/styles'; import type { GrantInvitedExpert } from '@/types/expertFinder'; -interface InvitedExpertRowProps { +interface GrantInvitedExpertCardProps { expert: GrantInvitedExpert; className?: string; } @@ -44,7 +44,7 @@ function ExpertSourceLinks({ expert }: { expert: GrantInvitedExpert }) { ); } -export function InvitedExpertRow({ expert, className }: InvitedExpertRowProps) { +export function GrantInvitedExpertCard({ expert, className }: GrantInvitedExpertCardProps) { const name = expert.displayName || expert.name; const title = expert.title?.trim() || ''; const university = expert.affiliation?.trim() || ''; diff --git a/components/Funding/InvitedExpertsPanel.tsx b/components/Funding/GrantInvitedExpertsGrid.tsx similarity index 70% rename from components/Funding/InvitedExpertsPanel.tsx rename to components/Funding/GrantInvitedExpertsGrid.tsx index 4859a59d4..3b7b33cdd 100644 --- a/components/Funding/InvitedExpertsPanel.tsx +++ b/components/Funding/GrantInvitedExpertsGrid.tsx @@ -5,36 +5,40 @@ import { cn } from '@/utils/styles'; import { PaginationButton } from '@/components/ui/PaginationButton'; import { ExpertFinderService } from '@/services/expertFinder.service'; import type { GrantInvitedExpert } from '@/types/expertFinder'; -import { InvitedExpertRow } from './InvitedExpertRow'; +import { GrantInvitedExpertCard } from './GrantInvitedExpertCard'; -export const INVITED_EXPERTS_GRID_CLASS = 'grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4'; +export const GRANT_INVITED_EXPERTS_GRID_CLASS = + 'grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4'; -interface InvitedExpertsPanelProps { +interface GrantInvitedExpertsGridProps { experts: GrantInvitedExpert[]; className?: string; } -export const InvitedExpertsPanel: FC = ({ experts, className }) => { +export const GrantInvitedExpertsGrid: FC = ({ + experts, + className, +}) => { if (experts.length === 0) { return null; } return (
-
+
{experts.map((expert, i) => ( - + ))}
); }; -export function InvitedExpertsGridSkeleton({ count }: { count?: number }) { +export function GrantInvitedExpertsGridSkeleton({ count }: { count?: number }) { const itemCount = count ?? ExpertFinderService.GRANT_INVITED_EXPERTS_PAGE_SIZE; return ( -
+
{Array.from({ length: itemCount }, (_, i) => (
void; } -export function InvitedExpertsPagination({ +export function GrantInvitedExpertsPagination({ page, totalPages, total, isLoading, onPageChange, -}: InvitedExpertsPaginationProps) { +}: GrantInvitedExpertsPaginationProps) { if (totalPages <= 1) return null; const hasPrevPage = page > 1; diff --git a/components/Funding/GrantInvitedExpertsSection.tsx b/components/Funding/GrantInvitedExpertsSection.tsx index 6e214a946..63e7c58d6 100644 --- a/components/Funding/GrantInvitedExpertsSection.tsx +++ b/components/Funding/GrantInvitedExpertsSection.tsx @@ -5,10 +5,10 @@ import { ChevronDown } from 'lucide-react'; import { cn } from '@/utils/styles'; import { useGrantInvitedExperts } from '@/hooks/useExpertFinder'; import { - InvitedExpertsGridSkeleton, - InvitedExpertsPanel, - InvitedExpertsPagination, -} from './InvitedExpertsPanel'; + GrantInvitedExpertsGridSkeleton, + GrantInvitedExpertsGrid, + GrantInvitedExpertsPagination, +} from './GrantInvitedExpertsGrid'; interface GrantInvitedExpertsSectionProps { unifiedDocumentId: number; @@ -100,7 +100,7 @@ export const GrantInvitedExpertsSection: FC = ( const panelContent = ( <> {isLoading && !hasLoaded ? ( - + ) : error ? (

Couldn't load invited experts

@@ -118,8 +118,12 @@ export const GrantInvitedExpertsSection: FC = (
) : hasLoaded && experts.length > 0 ? ( <> - {isLoading ? : } - + ) : ( + + )} + 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) => (