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
1 change: 1 addition & 0 deletions app/grant/[id]/[slug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default async function GrantSlugLayout({ params, children }: Props) {
return (
<GrantTabProvider defaultTab="details" grantId={grantId}>
<PageLayout
fundraiseGrantId={grantId ? Number(grantId) : undefined}
topBanner={
<WorkHeaderGrant
work={work}
Expand Down
7 changes: 2 additions & 5 deletions app/grant/[id]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { PostService } from '@/services/post.service';
import { getWorkMetadata } from '@/lib/metadata-helpers';
import { ProposalFeed } from '@/components/Funding/ProposalFeed';
import { ProposalSortAndFilters } from '@/components/Funding/ProposalSortAndFilters';
import { FundraiseProvider } from '@/contexts/FundraiseContext';
import { GrantContentSwitcher } from '@/components/Funding/GrantContentSwitcher';

interface Props {
Expand Down Expand Up @@ -48,10 +47,8 @@ export default async function GrantSlugPage({ params }: Props) {
hasDescription={!!grant?.description}
grantId={grantId}
>
<FundraiseProvider grantId={grantId ? Number(grantId) : undefined}>
{grant?.description && <ProposalSortAndFilters />}
<ProposalFeed />
</FundraiseProvider>
{grant?.description && <ProposalSortAndFilters />}
<ProposalFeed />
</GrantContentSwitcher>
);
}
5 changes: 3 additions & 2 deletions app/layouts/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
className?: string;
sidebarContentClassName?: string;
topBanner?: ReactNode;
fundraiseGrantId?: number;

Check warning on line 32 in app/layouts/PageLayout.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'fundraiseGrantId' PropType is defined but prop is never used

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AZ7cS6GOyA4QIXf3wDXd&open=AZ7cS6GOyA4QIXf3wDXd&pullRequest=910
/**
* Drop the 860px main-content cap and let content fill the page container
* (~1180px). Useful when `rightSidebar` is false and the page wants the
Expand Down Expand Up @@ -129,10 +130,10 @@
);
}

export function PageLayout(props: PageLayoutProps) {
export function PageLayout({ fundraiseGrantId, ...props }: PageLayoutProps) {

Check warning on line 133 in app/layouts/PageLayout.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=AZ7cS6GOyA4QIXf3wDXe&open=AZ7cS6GOyA4QIXf3wDXe&pullRequest=910
return (
<GrantProvider>
<FundraiseProvider>
<FundraiseProvider grantId={fundraiseGrantId}>
<FeedTabsVisibilityProvider>
<TopBarSlotProvider>
<PageLayoutInner {...props} />
Expand Down
42 changes: 31 additions & 11 deletions components/Funding/OpenFundingOpportunityModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useState, useTransition } from 'react';
import Link from 'next/link';
import { Dialog } from '@headlessui/react';
import {
Expand Down Expand Up @@ -109,17 +109,36 @@ export const OpenFundingOpportunityModal = ({
}: OpenFundingOpportunityModalProps) => {
const initialStep: Step = minimal ? 'method' : 'benefits';
const [step, setStep] = useState<Step>(initialStep);
const [pendingMethod, setPendingMethod] = useState<FundingOpportunityCreationMethod | null>(null);
const [isPending, startTransition] = useTransition();

const handleClose = () => {
if (isPending) return;
onClose();
};

const handleConfirmMethod = (method: FundingOpportunityCreationMethod) => {
setPendingMethod(method);
startTransition(() => {
onConfirm(method);
});
};

// Reset to the first step whenever the modal is reopened so a returning user
// always starts from the configured entry step rather than a stale step.
// Skip while a route transition is pending so the method step doesn't flash
// back to the initial step before navigation completes.
useEffect(() => {
if (!isOpen) setStep(initialStep);
}, [isOpen, initialStep]);
if (!isOpen && !isPending) {
setStep(initialStep);
setPendingMethod(null);
}
}, [isOpen, initialStep, isPending]);

return (
<BaseModal
isOpen={isOpen}
onClose={onClose}
isOpen={isOpen || isPending}
onClose={handleClose}
showCloseButton={false}
padding="p-0"
className={cn(
Expand All @@ -134,7 +153,7 @@ export const OpenFundingOpportunityModal = ({
{/* Mobile close button (lives in the title section on small screens) */}
<button
type="button"
onClick={onClose}
onClick={handleClose}
className="absolute top-4 right-4 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-black/5 text-gray-500 transition-colors hover:bg-black/10 hover:text-gray-700 md:hidden"
aria-label="Close"
>
Expand Down Expand Up @@ -172,7 +191,7 @@ export const OpenFundingOpportunityModal = ({
<div className="relative flex flex-1 flex-col p-6 md:p-10">
<button
type="button"
onClick={onClose}
onClick={handleClose}
className="absolute top-4 right-4 z-10 hidden h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 hover:text-gray-700 md:inline-flex"
aria-label="Close"
>
Expand Down Expand Up @@ -252,9 +271,10 @@ export const OpenFundingOpportunityModal = ({
key={option.id}
type="button"
onClick={() =>
option.id === 'upload' ? setStep('upload') : onConfirm(option.id)
option.id === 'upload' ? setStep('upload') : handleConfirmMethod(option.id)
}
className="group flex w-full items-center gap-4 rounded-xl border border-gray-200 bg-white px-4 py-3.5 text-left transition-colors hover:border-rhBlue-300 hover:bg-blue-50/50"
disabled={pendingMethod === option.id}
className="group flex w-full items-center gap-4 rounded-xl border border-gray-200 bg-white px-4 py-3.5 text-left transition-colors hover:border-rhBlue-300 hover:bg-blue-50/50 disabled:pointer-events-none disabled:opacity-60"
>
<div className="flex h-[46px] w-[46px] flex-shrink-0 items-center justify-center rounded-2xl bg-blue-50">
{option.icon}
Expand Down Expand Up @@ -282,7 +302,7 @@ export const OpenFundingOpportunityModal = ({
href={WHITE_GLOVE_BOOKING_URL}
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
onClick={handleClose}
className="group flex w-full items-center gap-4 rounded-xl border border-rhBlue-200 bg-blue-50/60 px-4 py-3.5 text-left transition-colors hover:border-rhBlue-300 hover:bg-blue-50"
>
<div className="flex h-[46px] w-[46px] flex-shrink-0 items-center justify-center rounded-2xl bg-rhBlue-600">
Expand All @@ -305,7 +325,7 @@ export const OpenFundingOpportunityModal = ({
description="Import a Word, OpenDocument, or Markdown file and we'll set up your funding opportunity from it."
documentType="GRANT"
onBack={() => setStep('method')}
onClose={onClose}
onClose={handleClose}
/>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions components/Funding/ProposalCount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { FC } from 'react';
import { useFundraises } from '@/contexts/FundraiseContext';

export const ProposalCount: FC = () => {
const { entries, isLoading } = useFundraises();
const { proposalCount, isLoading } = useFundraises();

if (isLoading) return <p className="mt-12 text-sm text-gray-600">&nbsp;</p>;

return (
<p className="mt-12 text-sm text-gray-600">
<span className="font-semibold">
{entries.length} proposal{entries.length !== 1 ? 's' : ''}
{proposalCount} proposal{proposalCount !== 1 ? 's' : ''}
</span>{' '}
competing for funding
</p>
Expand Down
2 changes: 1 addition & 1 deletion components/Funding/ProposalFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const ProposalFeed: FC<ProposalFeedProps> = ({ className }) => {
showPostHeaders={false}
noEntriesElement={
<div className="py-12 text-center">
<p className="text-gray-500">No proposals found</p>
<p className="text-gray-500">No proposals submitted yet</p>
</div>
}
/>
Expand Down
6 changes: 6 additions & 0 deletions components/Funding/ProposalSortAndFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ interface ProposalSortAndFiltersProps {
}

export const ProposalSortAndFilters: FC<ProposalSortAndFiltersProps> = ({ className }) => {
const { entries, isLoading } = useFundraises();

if (!isLoading && entries.length === 0) {
return null;
}

return (
<div className={cn('flex items-center justify-end mt-2 sm:mt-4 mb-2', className)}>
<SortDropdown />
Expand Down
12 changes: 9 additions & 3 deletions components/profile/ProfileHeroBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { VerifiedBadge } from '@/components/ui/VerifiedBadge';
import { Button } from '@/components/ui/Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBirthdayCake } from '@fortawesome/pro-light-svg-icons';
import { specificTimeSince } from '@/utils/date';
import { specificTimeSince, MEMBERSHIP_JUST_JOINED } from '@/utils/date';
import { AuthorProfile } from '@/types/authorProfile';
import { calculateProfileCompletion } from '@/utils/profileCompletion';
import { useUser } from '@/contexts/UserContext';
Expand Down Expand Up @@ -45,6 +45,8 @@ export function ProfileHeroBanner({ author, refetchAuthorInfo, tabBar }: Profile
if (!isOwnProfile) setIsEditModalOpen(false);
}, [isOwnProfile]);

const membershipDuration = author.createdDate ? specificTimeSince(author.createdDate) : null;

return (
<>
<HeroHeader tabBar={tabBar}>
Expand Down Expand Up @@ -100,13 +102,17 @@ export function ProfileHeroBanner({ author, refetchAuthorInfo, tabBar }: Profile

<div className="flex flex-col gap-1">
<ProfileEducation educations={author.education ?? []} />
{author.createdDate && (
{membershipDuration && (
<div className="flex items-baseline gap-2 text-gray-600">
<FontAwesomeIcon
icon={faBirthdayCake}
className="h-5 w-5 self-start text-[#6B7280]"
/>
<span>Member for {specificTimeSince(author.createdDate)}</span>
<span>
{membershipDuration === MEMBERSHIP_JUST_JOINED
? 'Member just joined'
: `Member for ${membershipDuration}`}
</span>
</div>
)}
</div>
Expand Down
22 changes: 21 additions & 1 deletion components/work/WorkHeader/WorkHeaderGrant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/Button';
import { Tabs } from '@/components/ui/Tabs';
import { SubmitProposalTooltip } from '@/components/tooltips/SubmitProposalTooltip';
import { useGrantTab, type GrantBannerTab } from '@/components/Funding/GrantPageContent';
import { useFundraises } from '@/contexts/FundraiseContext';
import type { GrantApplicationVisibility } from '@/types/grant';
import { WorkHeader } from './WorkHeader';
import { WorkHeaderGrantEyebrow } from './WorkHeaderGrantEyebrow';
Expand Down Expand Up @@ -37,6 +38,7 @@ export function WorkHeaderGrant({
}: WorkHeaderGrantProps) {
const [isApplyModalOpen, setIsApplyModalOpen] = useState(false);
const { activeTab, setActiveTab, activity } = useGrantTab();
const { proposalCount } = useFundraises();

const handleTabChange = useCallback(
(tabId: string) => setActiveTab(tabId as GrantBannerTab),
Expand Down Expand Up @@ -83,7 +85,25 @@ export function WorkHeaderGrant({

const grantTabs = [
{ id: 'details' as const, label: 'Details' },
{ id: 'proposals' as const, label: 'Proposals' },
{
id: 'proposals' as const,
label: (
<div className="flex items-center">
<span>Proposals</span>
{proposalCount > 0 && (
<span
className={`ml-2 py-0.5 px-2 rounded-full text-xs ${
activeTab === 'proposals'
? 'bg-primary-100 text-primary-600'
: 'bg-gray-100 text-gray-600'
}`}
>
{proposalCount}
</span>
)}
</div>
),
},
{
id: 'activity' as const,
label: (
Expand Down
12 changes: 11 additions & 1 deletion contexts/FundraiseContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface FundraiseProviderProps {

export function FundraiseProvider({ children, grantId }: FundraiseProviderProps) {
const [entries, setEntries] = useState<FeedEntry[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
Expand Down Expand Up @@ -81,6 +82,12 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps)
const [activated, setActivated] = useState(false);
const activate = useCallback(() => setActivated(true), []);

useEffect(() => {
if (grantId != null) {
setActivated(true);
}
}, [grantId]);

const fetchProposals = useCallback(async () => {
setEntries([]);
setIsLoading(true);
Expand All @@ -95,6 +102,7 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps)
ordering: feedParams.ordering,
});
setEntries(result.entries);
setTotalCount(result.count);
setHasMore(result.hasMore && result.entries.length >= PAGE_SIZE);
setPage(1);
} catch (error) {
Expand Down Expand Up @@ -126,6 +134,7 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps)
ordering: feedParams.ordering,
});
setEntries((prev) => [...prev, ...result.entries]);
setTotalCount(result.count);
setHasMore(result.hasMore && result.entries.length >= PAGE_SIZE);
setPage(nextPage);
} catch (error) {
Expand Down Expand Up @@ -164,7 +173,7 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps)
isLoadingMore,
hasMore,
loadMore,
proposalCount: entries.length,
proposalCount: totalCount,
statusFilter,
setStatusFilter,
taxDeductible,
Expand All @@ -178,6 +187,7 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps)
}),
[
entries,
totalCount,
activated,
isLoading,
isLoadingMore,
Expand Down
4 changes: 3 additions & 1 deletion services/feed.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class FeedService {
status?: string;
userId?: string;
viewAsUserId?: number;
}): Promise<{ entries: FeedEntry[]; hasMore: boolean }> {
}): Promise<{ entries: FeedEntry[]; hasMore: boolean; count: number }> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.pageSize) queryParams.append('page_size', params.pageSize.toString());
Expand Down Expand Up @@ -84,13 +84,15 @@ export class FeedService {
return {
entries: transformedEntries,
hasMore: !!response.next,
count: response.count ?? transformedEntries.length,
};
} catch (error) {
console.error('Error fetching feed:', error);
// Return empty entries on error
return {
entries: [],
hasMore: false,
count: 0,
};
}
}
Expand Down
4 changes: 3 additions & 1 deletion utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export function isDeadlineInFuture(deadline: string): boolean {
return deadlineDate.isAfter(now);
}

export const MEMBERSHIP_JUST_JOINED = 'just joined';

export function specificTimeSince(dateInput: string | Date): string {
const now = dayjs.utc();
const joined = dayjs.utc(dateInput);
Expand All @@ -157,7 +159,7 @@ export function specificTimeSince(dateInput: string | Date): string {
if (days > 0) result.push(`${days} day${days > 1 ? 's' : ''}`);

if (result.length === 0) {
return 'just joined';
return MEMBERSHIP_JUST_JOINED;
} else if (result.length === 1) {
return result[0];
} else if (result.length === 2) {
Expand Down