diff --git a/app/journal-new/JournalNewPageContent.tsx b/app/journal-new/JournalNewPageContent.tsx new file mode 100644 index 000000000..2aafca27f --- /dev/null +++ b/app/journal-new/JournalNewPageContent.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; +import { FeedContent } from '@/components/Feed/FeedContent'; +import { useFeed } from '@/hooks/useFeed'; +import { JournalV2FeedEntryItem } from '@/components/Journal/JournalV2FeedEntryItem'; +import { + JournalV2SortAndFilters, + type JournalSortOption, +} from '@/components/Journal/JournalV2SortAndFilters'; + +export function JournalNewPageContent() { + const [journalSort, setJournalSort] = useState('best'); + + const { + entries, + isLoading, + hasMore, + loadMore, + restoredScrollPosition, + page, + lastClickedEntryId, + } = useFeed('all', { + endpoint: 'journal_v2_feed', + ordering: journalSort, + }); + + return ( + } + ordering={journalSort} + activeTab="journal-new" + restoredScrollPosition={restoredScrollPosition} + page={page} + lastClickedEntryId={lastClickedEntryId ?? undefined} + showGrantHeaders={false} + showPostHeaders={false} + showFundraiseHeaders={false} + noEntriesElement={ +
+

No journal entries yet

+
+ } + renderEntry={({ + entry, + index, + ordering, + registerVisibleItem, + unregisterVisibleItem, + getVisibleItems, + }) => ( + + )} + /> + ); +} diff --git a/app/journal-new/page.tsx b/app/journal-new/page.tsx new file mode 100644 index 000000000..3413ce31b --- /dev/null +++ b/app/journal-new/page.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link'; +import { Metadata } from 'next'; +import { Plus } from 'lucide-react'; +import { buildOpenGraphMetadata } from '@/lib/metadata'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { HeroHeader } from '@/components/ui/HeroHeader'; +import { RHJRightSidebar } from '@/components/Journal/RHJRightSidebar'; +import { JournalNewPageContent } from './JournalNewPageContent'; + +export const metadata: Metadata = buildOpenGraphMetadata({ + title: 'ResearchHub Journal', + description: 'Browse completed funded proposals and published Registered Reports.', + url: '/journal-new', +}); + +export default function JournalNewPage() { + return ( + + Browse funded proposals, Registered Reports, and results. +

+ } + cta={ + + + Proposal + + } + /> + } + rightSidebar={} + > + +
+ ); +} diff --git a/components/Feed/FeedContent.tsx b/components/Feed/FeedContent.tsx index 60c50033d..9deb86cba 100644 --- a/components/Feed/FeedContent.tsx +++ b/components/Feed/FeedContent.tsx @@ -48,6 +48,14 @@ interface FeedContentProps { shouldRenderBountyAsComment?: boolean; showBountyInfo?: boolean; abstractCollapsedByDefault?: boolean; + renderEntry?: (props: { + entry: FeedEntry; + index: number; + ordering?: string; + registerVisibleItem: (index: number, unifiedDocumentId: string) => void; + unregisterVisibleItem: (index: number, unifiedDocumentId: string) => void; + getVisibleItems: (clickedUnifiedDocumentId: string) => string[]; + }) => ReactNode; /** * Drop the default `max-w-4xl` cap on the feed list so it fills its parent. * Use when the surrounding layout is already wider (e.g. sidebar-less pages). @@ -84,6 +92,7 @@ export const FeedContent: FC = ({ shouldRenderBountyAsComment, showBountyInfo = false, abstractCollapsedByDefault, + renderEntry, wideContent = false, skeletonVariant, }) => { @@ -187,7 +196,16 @@ export const FeedContent: FC = ({ } } - const feedItem = ( + const feedItem = renderEntry ? ( + renderEntry({ + entry, + index, + ordering, + registerVisibleItem, + unregisterVisibleItem, + getVisibleItems, + }) + ) : ( void; + unregisterVisibleItem: (index: number, unifiedDocumentId: string) => void; + getVisibleItems: (clickedUnifiedDocumentId: string) => string[]; +} + +const STAGE_ICONS: Record = { + funding_opportunity: Landmark, + proposal: Coins, + registered_report: FileInput, + preprint: FileText, +}; + +const TRACKER_CLIP_PATHS = { + first: 'polygon(0 0, 100% 0, calc(100% - 12px) 100%, 0 100%)', + middle: 'polygon(12px 0, 100% 0, calc(100% - 12px) 100%, 0 100%)', + last: 'polygon(12px 0, 100% 0, 100% 100%, 0 100%)', +} as const; + +/** Resolves the angled tracker shape for a stage position. */ +function resolveTrackerClipPath(stepIndex: number, stepCount: number): string { + if (stepIndex === 0) return TRACKER_CLIP_PATHS.first; + if (stepIndex === stepCount - 1) return TRACKER_CLIP_PATHS.last; + return TRACKER_CLIP_PATHS.middle; +} + +export const JournalV2FeedEntryItem: FC = ({ + entry, + index, + feedOrdering, + registerVisibleItem, + unregisterVisibleItem, + getVisibleItems, +}) => { + const { updateLastClickedEntryId } = useNavigation(); + const unifiedDocumentId = getUnifiedDocumentId(entry); + const viewModel = buildJournalV2FeedItemViewModel(entry); + + const { ref } = useInView({ + threshold: 0, + rootMargin: '50px', + onChange: (inView) => { + if (!unifiedDocumentId) return; + if (inView) { + registerVisibleItem(index, unifiedDocumentId); + } else { + unregisterVisibleItem(index, unifiedDocumentId); + } + }, + }); + + const getImpressions = useCallback(() => { + if (!unifiedDocumentId) return undefined; + const visibleItems = getVisibleItems(unifiedDocumentId); + return visibleItems.length > 0 ? visibleItems : undefined; + }, [unifiedDocumentId, getVisibleItems]); + + const { handleFeedItemClick } = useFeedItemAnalyticsTracking({ + entry, + feedPosition: index + 1, + feedOrdering, + impression: getImpressions(), + }); + + if (!viewModel) { + return null; + } + + const entryIdKey = `JOURNAL:${entry.id}`; + + const handleCardClick = () => { + handleFeedItemClick(); + updateLastClickedEntryId(entryIdKey); + }; + + const reviewSummary = viewModel.reviewSummary; + const proposalHref = viewModel.trackerSteps.find((step) => step.stage === 'proposal')?.href; + + const renderTrackerStep = (step: JournalV2StageLink, stepIndex: number) => { + const StepIcon = STAGE_ICONS[step.stage]; + const className = cn( + 'relative flex min-h-[54px] items-center justify-center gap-2 border px-3 py-2 text-xs font-semibold transition-colors', + step.href + ? 'border-primary-500 bg-primary-500 text-white hover:bg-primary-600' + : 'border-gray-200 bg-white text-gray-400', + stepIndex === 0 && 'rounded-l-lg', + stepIndex === viewModel.trackerSteps.length - 1 && 'rounded-r-lg' + ); + const style = { + clipPath: resolveTrackerClipPath(stepIndex, viewModel.trackerSteps.length), + }; + const content = ( + <> + + {step.label} + + ); + + if (!step.href) { + return ( +
+ {content} +
+ ); + } + + return ( + e.stopPropagation()} + className={className} + style={style} + > + {content} + + ); + }; + + return ( +
+
+ + {viewModel.imageUrl ? ( + {viewModel.title} + ) : ( +
+ )} + +
+
+
+ {viewModel.currentStageLabel} +
+

+ {viewModel.title} +

+
+ +
+
+
+ Average Review +
+ {reviewSummary ? ( + + } + position="top" + width="w-[320px]" + > + + + {reviewSummary.average.toFixed(1)}/5 + + + ) : ( +
--/5
+ )} +
+ + + View + + +
+
+ + +
+ {viewModel.trackerSteps.map((step, stepIndex) => renderTrackerStep(step, stepIndex))} +
+
+
+ ); +}; diff --git a/components/Journal/JournalV2SortAndFilters.tsx b/components/Journal/JournalV2SortAndFilters.tsx new file mode 100644 index 000000000..d8649b3d7 --- /dev/null +++ b/components/Journal/JournalV2SortAndFilters.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { FC } from 'react'; +import { cn } from '@/utils/styles'; + +export type JournalSortOption = 'best' | 'newest' | 'peer_review_score'; + +export const JOURNAL_SORT_OPTIONS: ReadonlyArray<{ + readonly label: string; + readonly value: JournalSortOption; +}> = [ + { label: 'Best', value: 'best' }, + { label: 'Newest', value: 'newest' }, + { label: 'Review score', value: 'peer_review_score' }, +]; + +interface SortDropdownProps { + readonly sortBy: JournalSortOption; + readonly onSortChange: (value: JournalSortOption) => void; +} + +function SortDropdown({ sortBy, onSortChange }: Readonly) { + return ( + + ); +} + +interface JournalV2SortAndFiltersProps { + readonly className?: string; + readonly sortBy: JournalSortOption; + readonly onSortChange: (value: JournalSortOption) => void; +} + +export const JournalV2SortAndFilters: FC = ({ + className, + sortBy, + onSortChange, +}) => { + return ( +
+ +
+ ); +}; diff --git a/components/Journal/lib/journalV2FeedItem.ts b/components/Journal/lib/journalV2FeedItem.ts new file mode 100644 index 000000000..a0a8ca4da --- /dev/null +++ b/components/Journal/lib/journalV2FeedItem.ts @@ -0,0 +1,292 @@ +import { + FeedEntry, + FeedPaperContent, + FeedPostContent, + JournalV2FeedMetadata, + JournalV2LinkedWork, + Review, +} from '@/types/feed'; +import { buildWorkUrl } from '@/utils/url'; + +export type JournalV2Stage = 'funding_opportunity' | 'proposal' | 'registered_report' | 'preprint'; + +export interface JournalV2StageLink { + stage: JournalV2Stage; + label: string; + href?: string; +} + +export interface JournalV2ReviewSummary { + average: number; + count: number; + reviews: Review[]; +} + +export interface JournalV2FeedItemViewModel { + title: string; + href: string; + imageUrl?: string; + currentStageLabel: string; + reviewSummary?: JournalV2ReviewSummary; + trackerSteps: JournalV2StageLink[]; +} + +type JournalFeedContent = FeedPostContent | FeedPaperContent; + +/** Reads a finite review score without treating null as zero. */ +function readScore(value: unknown): number | undefined { + if (value === null || value === undefined || value === '') return undefined; + const score = Number(value); + return Number.isFinite(score) ? score : undefined; +} + +/** Reads the post-facing ID from transformed journal metadata. */ +function readLinkedWorkId(work?: JournalV2LinkedWork): number | undefined { + return work?.postId ?? work?.id ?? undefined; +} + +/** Builds a work URL from transformed journal metadata. */ +function buildHrefFromLinkedWork( + work: JournalV2LinkedWork | undefined, + contentType: 'funding_request' | 'paper' | 'post' | 'preregistration' +): string | undefined { + const id = readLinkedWorkId(work); + if (!id) return undefined; + + return buildWorkUrl({ + id, + slug: work?.slug, + contentType, + }); +} + +/** Builds the future Registered Report page URL. */ +function buildReportHref(id: string | number, slug?: string): string { + return slug ? `/report/${id}/${slug}` : `/report/${id}`; +} + +/** Builds the future Registered Report page URL from transformed journal metadata. */ +function buildReportHrefFromLinkedWork(work: JournalV2LinkedWork | undefined): string | undefined { + const id = readLinkedWorkId(work); + if (!id) return undefined; + + return buildReportHref(id, work?.slug); +} + +/** Returns true when the transformed feed content is a paper/preprint. */ +function isPaperContent(content: FeedEntry['content']): content is FeedPaperContent { + return content.contentType === 'PAPER'; +} + +/** Returns true when the transformed feed content is a post-like journal item. */ +function isPostContent(content: FeedEntry['content']): content is FeedPostContent { + return content.contentType === 'POST' || content.contentType === 'PREREGISTRATION'; +} + +/** Returns true when the item represents a Registered Report post. */ +function isRegisteredReportContent( + content: JournalFeedContent, + journalV2?: JournalV2FeedMetadata +): boolean { + return ( + (isPostContent(content) && content.postType === 'REGISTERED_REPORT') || + journalV2?.documentType === 'REGISTERED_REPORT' || + journalV2?.journalState === 'registered_report' + ); +} + +/** Builds the primary card link for the transformed journal item. */ +function buildPrimaryHref(content: JournalFeedContent, journalV2?: JournalV2FeedMetadata): string { + if (isPaperContent(content)) { + return buildWorkUrl({ id: content.id, slug: content.slug, contentType: 'paper' }); + } + + if (isRegisteredReportContent(content, journalV2)) { + return buildReportHref(content.id, content.slug); + } + + if (content.contentType === 'PREREGISTRATION' || content.postType === 'PREREGISTRATION') { + return buildWorkUrl({ id: content.id, slug: content.slug, contentType: 'preregistration' }); + } + + return buildWorkUrl({ id: content.id, slug: content.slug, contentType: 'post' }); +} + +/** Builds the Funding Opportunity tracker link from transformed post IDs when present. */ +function buildFundingOpportunityHref(journalV2?: JournalV2FeedMetadata): string | undefined { + if (journalV2?.postIds) { + const grantPostId = journalV2.postIds.grantPostId; + if (!grantPostId) return undefined; + + return buildWorkUrl({ + id: grantPostId, + slug: journalV2.fundingOpportunity?.slug, + contentType: 'funding_request', + }); + } + + return buildHrefFromLinkedWork(journalV2?.fundingOpportunity, 'funding_request'); +} + +/** Builds the Proposal tracker link from transformed post IDs when present. */ +function buildProposalHref( + journalV2: JournalV2FeedMetadata | undefined, + content: JournalFeedContent, + primaryHref: string +): string | undefined { + if (journalV2?.postIds) { + const proposalPostId = journalV2.postIds.proposalPostId; + if (!proposalPostId) return undefined; + + return buildWorkUrl({ + id: proposalPostId, + slug: journalV2.proposal?.slug, + contentType: 'preregistration', + }); + } + + if (isPostContent(content) && content.contentType === 'PREREGISTRATION') { + return primaryHref; + } + + return buildHrefFromLinkedWork(journalV2?.proposal, 'preregistration'); +} + +/** Builds the Registered Report tracker link when that stage exists. */ +function buildRegisteredReportHref( + content: JournalFeedContent, + journalV2: JournalV2FeedMetadata | undefined, + primaryHref: string +): string | undefined { + if (isRegisteredReportContent(content, journalV2)) { + return primaryHref; + } + + const registeredReportHref = buildReportHrefFromLinkedWork(journalV2?.registeredReport); + if (registeredReportHref) return registeredReportHref; + + const registeredReportPostId = journalV2?.registeredReportPostId; + if (!registeredReportPostId) return undefined; + + return buildReportHref(registeredReportPostId, journalV2?.registeredReport?.slug); +} + +/** Builds the Preprint tracker link when that stage exists. */ +function buildPreprintHref( + content: JournalFeedContent, + journalV2: JournalV2FeedMetadata | undefined, + primaryHref: string +): string | undefined { + if (isPaperContent(content)) { + return primaryHref; + } + + return buildHrefFromLinkedWork(journalV2?.preprint, 'paper'); +} + +/** Names the current stage represented by this feed entry. */ +function resolveCurrentStageLabel( + content: JournalFeedContent, + journalV2?: JournalV2FeedMetadata +): string { + if ( + isPaperContent(content) || + journalV2?.journalState === 'preprint' || + journalV2?.journalState === 'result' + ) { + return 'Preprint'; + } + + if (isRegisteredReportContent(content, journalV2)) { + return 'Registered Report'; + } + + return 'Funded Proposal'; +} + +/** Normalizes review scores to the x/5 scale shown in journal cards. */ +function normalizeScoreToFive(score: number): number { + return score > 5 ? score / 2 : score; +} + +/** Converts transformed reviews to the x/5 score scale used by the journal feed. */ +function buildReviewFromTransformed(review: Review): Review | undefined { + const score = readScore(review.score); + if (score === undefined) return undefined; + + return { + ...review, + score: normalizeScoreToFive(score), + }; +} + +/** Collects reviews from transformed content. */ +function collectReviews(content: JournalFeedContent): Review[] { + return Array.isArray(content.reviews) + ? content.reviews + .map((review) => buildReviewFromTransformed(review)) + .filter((review): review is Review => !!review) + : []; +} + +/** Calculates the average review score shown by the journal feed. */ +function calculateReviewSummary(reviews: Review[]): JournalV2ReviewSummary | undefined { + if (reviews.length === 0) return undefined; + + const average = reviews.reduce((sum, review) => sum + review.score, 0) / reviews.length; + return { + average: Math.round(average * 10) / 10, + count: reviews.length, + reviews, + }; +} + +/** Builds the normalized data needed to render one journal v2 feed item. */ +export function buildJournalV2FeedItemViewModel( + entry: FeedEntry +): JournalV2FeedItemViewModel | undefined { + const content = entry.content; + if (!isPostContent(content) && !isPaperContent(content)) { + return undefined; + } + + const { journalV2 } = entry; + const primaryHref = buildPrimaryHref(content, journalV2); + const fundingOpportunityHref = buildFundingOpportunityHref(journalV2); + const proposalHref = buildProposalHref(journalV2, content, primaryHref); + const registeredReportHref = buildRegisteredReportHref(content, journalV2, primaryHref); + const preprintHref = buildPreprintHref(content, journalV2, primaryHref); + const imageUrl = isPaperContent(content) + ? content.previewImage || content.previewThumbnail || undefined + : content.previewImage || content.fundraise?.postImage || undefined; + + return { + title: content.title, + href: primaryHref, + imageUrl, + currentStageLabel: resolveCurrentStageLabel(content, journalV2), + reviewSummary: calculateReviewSummary(collectReviews(content)), + trackerSteps: [ + { + stage: 'funding_opportunity', + label: 'Funding Opportunity', + href: fundingOpportunityHref, + }, + { + stage: 'proposal', + label: 'Proposal', + href: proposalHref, + }, + { + stage: 'registered_report', + label: 'Registered Report', + href: registeredReportHref, + }, + { + stage: 'preprint', + label: 'Preprint', + href: preprintHref, + }, + ], + }; +} diff --git a/components/tooltips/PeerReviewTooltip.tsx b/components/tooltips/PeerReviewTooltip.tsx index 1133515d2..5d970fe9f 100644 --- a/components/tooltips/PeerReviewTooltip.tsx +++ b/components/tooltips/PeerReviewTooltip.tsx @@ -20,7 +20,10 @@ export function PeerReviewTooltip({ reviews, averageScore, href }: PeerReviewToo }; const assessedReviews = reviews.filter((r) => r.isAssessed); - const visibleReviews = assessedReviews.slice(0, MAX_VISIBLE_REVIEWERS); + const visibleReviews = (assessedReviews.length > 0 ? assessedReviews : reviews).slice( + 0, + MAX_VISIBLE_REVIEWERS + ); const reviewsHref = href ? (href.endsWith('/reviews') ? href : `${href}/reviews`) : undefined; return ( diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts index 39c5d6079..e47b55818 100644 --- a/hooks/useFeed.ts +++ b/hooks/useFeed.ts @@ -12,7 +12,7 @@ interface UseFeedOptions { hubSlug?: string; contentType?: string; source?: FeedSource; - endpoint?: 'feed' | 'funding_feed' | 'grant_feed'; + endpoint?: 'feed' | 'funding_feed' | 'grant_feed' | 'journal_v2_feed'; fundraiseStatus?: 'OPEN' | 'CLOSED'; grantId?: number; createdBy?: number; diff --git a/services/feed.service.ts b/services/feed.service.ts index 31a8dc658..39bbc4df7 100644 --- a/services/feed.service.ts +++ b/services/feed.service.ts @@ -9,6 +9,7 @@ export class FeedService { private static readonly BASE_PATH = '/api/feed'; private static readonly FUNDING_PATH = '/api/funding_feed'; private static readonly GRANT_PATH = '/api/grant_feed'; + private static readonly JOURNAL_V2_PATH = '/api/journal_v2_feed'; private static readonly PENDING_MODERATION_PATH = '/api/moderator_feed/pending_moderation'; static async getFeed(params?: { @@ -18,7 +19,7 @@ export class FeedService { hubSlug?: string; contentType?: string; source?: 'all' | 'researchhub'; - endpoint?: 'feed' | 'funding_feed' | 'grant_feed' | 'pending_moderation'; + endpoint?: 'feed' | 'funding_feed' | 'grant_feed' | 'journal_v2_feed' | 'pending_moderation'; fundraiseStatus?: 'OPEN' | 'CLOSED'; grantId?: number; createdBy?: number; @@ -57,6 +58,7 @@ export class FeedService { const endpointToPath: Record = { funding_feed: this.FUNDING_PATH, grant_feed: this.GRANT_PATH, + journal_v2_feed: this.JOURNAL_V2_PATH, pending_moderation: this.PENDING_MODERATION_PATH, }; const basePath = endpointToPath[params?.endpoint ?? ''] ?? this.BASE_PATH; diff --git a/types/feed.ts b/types/feed.ts index d6affb928..743747050 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -280,6 +280,28 @@ export interface AssociatedGrant { numApplicants: number; } +export interface JournalV2PostIds { + grantPostId: number | null; + proposalPostId: number | null; +} + +export interface JournalV2LinkedWork { + id?: number; + postId?: number | null; + slug?: string; +} + +export interface JournalV2FeedMetadata { + documentType?: string; + journalState?: string; + postIds?: JournalV2PostIds; + proposal?: JournalV2LinkedWork; + fundingOpportunity?: JournalV2LinkedWork; + registeredReport?: JournalV2LinkedWork; + registeredReportPostId?: number; + preprint?: JournalV2LinkedWork; +} + export interface FeedEntry { id: string; recommendationId: string | null; @@ -302,6 +324,7 @@ export interface FeedEntry { externalMetrics?: ExternalMetrics; nonprofit?: Nonprofit; associatedGrants?: AssociatedGrant[]; + journalV2?: JournalV2FeedMetadata; searchMetadata?: { highlightedTitle?: string; highlightedSnippet?: string; @@ -322,6 +345,10 @@ export interface RawApiFeedEntry { recommendation_id: string | null; content_type: string; content_object: any; + post_ids?: { + grant_post_id: number | null; + proposal_post_id: number | null; + }; created_date: string; action: string; action_date: string; @@ -458,6 +485,112 @@ export function getUnifiedDocumentId(content_object: any): string | undefined { return undefined; } +const asRecord = (value: unknown): Record | undefined => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; + +const firstRecord = (...values: unknown[]): Record | undefined => { + for (const value of values) { + if (Array.isArray(value)) { + const record = value.map((item) => asRecord(item)).find(Boolean); + if (record) return record; + } else { + const record = asRecord(value); + if (record) return record; + } + } + + return undefined; +}; + +const asNumber = (value: unknown): number | undefined => { + const numberValue = Number(value); + return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined; +}; + +const transformJournalV2LinkedWork = (value: unknown): JournalV2LinkedWork | undefined => { + const record = asRecord(value); + if (!record) return undefined; + + const id = asNumber(record.id); + const postId = asNumber(record.post_id) ?? asNumber(record.postId) ?? null; + const slug = typeof record.slug === 'string' && record.slug.length > 0 ? record.slug : undefined; + + if (!id && !postId && !slug) return undefined; + + return { + id, + postId, + slug, + }; +}; + +const transformJournalV2FeedMetadata = ( + feedEntry: RawApiFeedEntry +): JournalV2FeedMetadata | undefined => { + const contentObject = asRecord(feedEntry.content_object); + if (!contentObject) return undefined; + + const fundraise = asRecord(contentObject.fundraise); + const fundingOpportunity = firstRecord( + contentObject.funding_opportunity, + contentObject.fundingOpportunity, + contentObject.grant, + fundraise?.grant + ); + const registeredReport = firstRecord( + asRecord(contentObject.registered_report)?.post, + asRecord(contentObject.registeredReport)?.post, + contentObject.registered_report, + contentObject.registeredReport, + contentObject.registered_report_post, + contentObject.registeredReportPost + ); + const preprint = firstRecord( + contentObject.preprint, + contentObject.paper, + contentObject.result, + contentObject.results + ); + + const metadata: JournalV2FeedMetadata = { + documentType: + typeof contentObject.type === 'string' && contentObject.type.length > 0 + ? contentObject.type + : undefined, + journalState: + typeof contentObject.journal_state === 'string' && contentObject.journal_state.length > 0 + ? contentObject.journal_state + : undefined, + postIds: feedEntry.post_ids + ? { + grantPostId: feedEntry.post_ids.grant_post_id, + proposalPostId: feedEntry.post_ids.proposal_post_id, + } + : undefined, + proposal: transformJournalV2LinkedWork(contentObject.proposal), + fundingOpportunity: transformJournalV2LinkedWork(fundingOpportunity), + registeredReport: transformJournalV2LinkedWork(registeredReport), + registeredReportPostId: + asNumber(contentObject.registered_report_post_id) ?? + asNumber(contentObject.registeredReportPostId), + preprint: transformJournalV2LinkedWork(preprint), + }; + + const hasJournalV2Metadata = + Boolean(metadata.postIds) || + Boolean(metadata.journalState) || + metadata.documentType === 'REGISTERED_REPORT' || + Boolean(metadata.proposal) || + Boolean(metadata.fundingOpportunity) || + Boolean(metadata.registeredReport) || + Boolean(metadata.registeredReportPostId) || + Boolean(metadata.preprint); + + return hasJournalV2Metadata ? metadata : undefined; +}; + export type TransformedContent = Content & BaseTransformed; export type TransformedFeedEntry = FeedEntry & BaseTransformed; @@ -931,12 +1064,12 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { : [], }; - // Add fundraise data if it's a PREREGISTRATION and has fundraise data - if (isPreregistration && content_object.fundraise) { + // Journal v2 registered reports also carry the source proposal fundraise. + if (content_object.fundraise) { try { postEntry.fundraise = transformFundraise(content_object.fundraise); } catch (fundraiseError) { - console.error('Error transforming fundraise for proposal:', fundraiseError); + console.error('Error transforming fundraise for post:', fundraiseError); } } @@ -1082,6 +1215,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { image: g.image ?? null, numApplicants: g.num_applicants ?? 0, })), + journalV2: transformJournalV2FeedMetadata(feedEntry), nonprofit: nonprofit ? { id: nonprofit.id,