Skip to content

Commit afbd78c

Browse files
committed
Add Open Rounds tab to applicants page and filter round items from listings
- Add new "Open Rounds" tab to /applicants page navigation - Create /applicants/open-rounds page listing active grant rounds - Filter AGR26-tagged items from regular RFP and Wishlist listings - Add getRoundTags() helper to extract round tags from MDX files - Update getGrantInitiativeItems() to exclude round-tagged items by default
1 parent 85761f6 commit afbd78c

File tree

4 files changed

+237
-17
lines changed

4 files changed

+237
-17
lines changed

src/components/layout/ApplicantsLayout.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
APPLICANTS_TABS_MAP,
1212
APPLICANTS_URL,
1313
GRANTEE_FINANCE_URL,
14+
OPEN_ROUNDS_URL,
1415
OFFICE_HOURS_URL,
1516
WISHLIST_URL,
1617
RFP_URL
@@ -26,18 +27,22 @@ const getTabIndexFromPath = (pathname: string): number => {
2627
return APPLICANTS_TABS_MAP[pathname];
2728
}
2829

29-
if (pathname.startsWith('/applicants/office-hours')) {
30+
if (pathname.startsWith('/applicants/open-rounds')) {
3031
return 1;
3132
}
3233

33-
if (pathname.startsWith('/applicants/wishlist')) {
34+
if (pathname.startsWith('/applicants/office-hours')) {
3435
return 2;
3536
}
3637

37-
if (pathname.startsWith('/applicants/rfp')) {
38+
if (pathname.startsWith('/applicants/wishlist')) {
3839
return 3;
3940
}
4041

42+
if (pathname.startsWith('/applicants/rfp')) {
43+
return 4;
44+
}
45+
4146
if (pathname.startsWith('/applicants')) {
4247
return 0;
4348
}
@@ -72,7 +77,7 @@ export const ApplicantsLayout = ({ children }: Props) => {
7277
case 1:
7378
router.push(
7479
{
75-
pathname: OFFICE_HOURS_URL
80+
pathname: OPEN_ROUNDS_URL
7681
},
7782
undefined,
7883
{ scroll: false }
@@ -82,14 +87,24 @@ export const ApplicantsLayout = ({ children }: Props) => {
8287
case 2:
8388
router.push(
8489
{
85-
pathname: WISHLIST_URL
90+
pathname: OFFICE_HOURS_URL
8691
},
8792
undefined,
8893
{ scroll: false }
8994
);
9095
break;
9196

9297
case 3:
98+
router.push(
99+
{
100+
pathname: WISHLIST_URL
101+
},
102+
undefined,
103+
{ scroll: false }
104+
);
105+
break;
106+
107+
case 4:
93108
router.push(
94109
{
95110
pathname: RFP_URL

src/constants.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export const SIDEBAR_RFP_LINKS: SidebarLink[] = [
5353
{ text: 'Apply', href: `${RFP_URL}/#apply` }
5454
];
5555

56+
export const OPEN_ROUNDS_URL = '/applicants/open-rounds';
57+
export const SIDEBAR_OPEN_ROUNDS_LINKS: SidebarLink[] = [
58+
{ text: 'Summary', href: `${OPEN_ROUNDS_URL}/#description` },
59+
{ text: 'Active Rounds', href: `${OPEN_ROUNDS_URL}/#active-rounds` }
60+
];
61+
5662
// rounds
5763
export const ROUNDS_URL = '/rounds';
5864

@@ -120,18 +126,19 @@ export const REMIX_LABS_URL = 'https://remix-project.org/';
120126
export const POWDR_LABS_URL = 'https://www.powdr.org/';
121127

122128
// applicants tabs
123-
export const APPLICANTS_TABS = ['Overview', 'Office Hours', 'Wishlist', 'RFPs'];
129+
export const APPLICANTS_TABS = ['Overview', 'Open Rounds', 'Office Hours', 'Wishlist', 'RFPs'];
124130
export const APPLICANTS_TABS_MAP: TabsMap = {
125131
[APPLICANTS_URL]: 0,
126-
[OFFICE_HOURS_URL]: 1,
127-
[OFFICE_HOURS_APPLY_URL]: 1,
128-
[OFFICE_HOURS_THANK_YOU_PAGE_URL]: 1,
129-
[WISHLIST_URL]: 2,
130-
[WISHLIST_APPLY_URL]: 2,
131-
[WISHLIST_THANK_YOU_PAGE_URL]: 2,
132-
[RFP_URL]: 3,
133-
[RFP_APPLY_URL]: 3,
134-
[RFP_THANK_YOU_PAGE_URL]: 3
132+
[OPEN_ROUNDS_URL]: 1,
133+
[OFFICE_HOURS_URL]: 2,
134+
[OFFICE_HOURS_APPLY_URL]: 2,
135+
[OFFICE_HOURS_THANK_YOU_PAGE_URL]: 2,
136+
[WISHLIST_URL]: 3,
137+
[WISHLIST_APPLY_URL]: 3,
138+
[WISHLIST_THANK_YOU_PAGE_URL]: 3,
139+
[RFP_URL]: 4,
140+
[RFP_APPLY_URL]: 4,
141+
[RFP_THANK_YOU_PAGE_URL]: 4
135142
};
136143

137144
// about tabs

src/lib/sf/index.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
import fs from 'fs';
2+
import path from 'path';
23
import jsforce from 'jsforce';
34
import jwt from 'jsonwebtoken';
5+
import matter from 'gray-matter';
46
import type { File } from 'formidable';
57

68
import { GrantInitiative, GrantInitiativeSalesforceRecord, GrantInitiativeType } from '../../types';
79
import { truncateString } from '../../utils/truncateString';
810

11+
/**
12+
* Get all tags used by rounds (e.g., "AGR26")
13+
* These tags should be excluded from regular RFP/Wishlist listings
14+
*/
15+
function getRoundTags(): string[] {
16+
const roundsDirectory = path.join(process.cwd(), 'content/rounds');
17+
18+
if (!fs.existsSync(roundsDirectory)) {
19+
return [];
20+
}
21+
22+
const files = fs.readdirSync(roundsDirectory);
23+
const tags: string[] = [];
24+
25+
for (const file of files) {
26+
if (file.endsWith('.mdx')) {
27+
const filePath = path.join(roundsDirectory, file);
28+
const fileContents = fs.readFileSync(filePath, 'utf8');
29+
const { data } = matter(fileContents);
30+
31+
if (data.tag) {
32+
tags.push(data.tag);
33+
}
34+
}
35+
}
36+
37+
return tags;
38+
}
39+
940
const {
1041
SF_PROD_LOGIN_URL,
1142
SF_PROD_USERNAME,
@@ -163,9 +194,14 @@ const transformGrantInitiativeRecords = (
163194
/**
164195
* Get all active grant initiative items
165196
* @param type - The type of grant initiative (Wishlist, RFP)
197+
* @param options - Optional configuration
198+
* @param options.excludeRoundItems - If true, excludes items that belong to grant rounds (default: true)
166199
* @returns Promise with the grant initiative items
167200
*/
168-
export function getGrantInitiativeItems(type?: GrantInitiativeType) {
201+
export function getGrantInitiativeItems(
202+
type?: GrantInitiativeType,
203+
options: { excludeRoundItems?: boolean } = { excludeRoundItems: true }
204+
) {
169205
return new Promise<GrantInitiative[]>(async (resolve, reject) => {
170206
const conn = createConnection();
171207

@@ -186,7 +222,23 @@ export function getGrantInitiativeItems(type?: GrantInitiativeType) {
186222
return reject(err);
187223
}
188224

189-
return resolve(transformGrantInitiativeRecords(ret));
225+
let records = ret;
226+
227+
// Filter out items that belong to grant rounds
228+
if (options.excludeRoundItems !== false) {
229+
const roundTags = getRoundTags();
230+
231+
if (roundTags.length > 0) {
232+
records = ret.filter(record => {
233+
if (!record.Tags__c) return true;
234+
const itemTags = record.Tags__c.split(';').map(t => t.trim());
235+
// Exclude if any of the item's tags match a round tag
236+
return !itemTags.some(tag => roundTags.includes(tag));
237+
});
238+
}
239+
}
240+
241+
return resolve(transformGrantInitiativeRecords(records));
190242
});
191243
} catch (error) {
192244
return reject(error);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Box, Flex, Stack, Text, Badge } from '@chakra-ui/react';
2+
import { useInView } from 'react-intersection-observer';
3+
import type { GetStaticProps, NextPage } from 'next';
4+
5+
import {
6+
ApplicantsSidebar,
7+
PageSection,
8+
PageSubheading,
9+
PageText,
10+
PageMetadata
11+
} from '../../../components/UI';
12+
13+
import { ButtonLink } from '../../../components';
14+
import { SIDEBAR_OPEN_ROUNDS_LINKS, ROUNDS_URL } from '../../../constants';
15+
import { getActiveRounds } from '../../../lib/rounds';
16+
import { RoundFrontmatter } from '../../../types';
17+
18+
interface OpenRoundsProps {
19+
activeRounds: RoundFrontmatter[];
20+
}
21+
22+
const RoundCard = ({ round }: { round: RoundFrontmatter }) => {
23+
const { slug, name, description, itemType, endDate } = round;
24+
const roundUrl = `${ROUNDS_URL}/${slug}`;
25+
26+
// Format the end date
27+
const formattedEndDate = new Date(endDate).toLocaleDateString('en-US', {
28+
year: 'numeric',
29+
month: 'long',
30+
day: 'numeric'
31+
});
32+
33+
return (
34+
<Stack
35+
borderRadius='10px'
36+
bgGradient='linear(to-br, brand.ready.bgGradient.start 10%, brand.ready.bgGradient.end 100%)'
37+
w='100%'
38+
justifyContent='center'
39+
>
40+
<Flex
41+
alignItems='center'
42+
justifyContent='space-between'
43+
direction={{ base: 'column', md: 'row' }}
44+
px={{ base: 6, lg: 12 }}
45+
py={10}
46+
>
47+
<Stack mb={{ base: 6, md: 0 }} mr={{ base: 0, md: 12 }} flex={1}>
48+
<Flex alignItems='center' gap={3} mb={2}>
49+
<Text
50+
as='h3'
51+
fontSize='xl'
52+
fontWeight='bold'
53+
color='brand.heading'
54+
textAlign={{ base: 'center', md: 'left' }}
55+
>
56+
{name}
57+
</Text>
58+
<Badge colorScheme='green' fontSize='xs'>
59+
{itemType}
60+
</Badge>
61+
</Flex>
62+
63+
<PageText mb={2}>{description}</PageText>
64+
65+
<Text fontSize='sm' color='gray.600'>
66+
Applications close: {formattedEndDate}
67+
</Text>
68+
</Stack>
69+
70+
<Stack>
71+
<ButtonLink label='View &amp; Apply' link={roundUrl} width='200px' />
72+
</Stack>
73+
</Flex>
74+
</Stack>
75+
);
76+
};
77+
78+
const OpenRounds: NextPage<OpenRoundsProps> = ({ activeRounds }) => {
79+
const [ref, inView] = useInView({ threshold: 0.5 });
80+
const [ref2, inView2] = useInView({ threshold: 0, initialInView: false });
81+
82+
return (
83+
<>
84+
<PageMetadata
85+
title='Open Rounds'
86+
description='Browse active grant rounds and special funding opportunities from the Ethereum Ecosystem Support Program.'
87+
/>
88+
89+
<Box bg='white' position='relative' py={{ md: 12 }}>
90+
<Flex>
91+
<ApplicantsSidebar
92+
sidebarLinks={SIDEBAR_OPEN_ROUNDS_LINKS}
93+
sectionsInView={[inView, inView2]}
94+
/>
95+
96+
<Box w={{ lg: '70%' }} px={{ md: 20 }} pr={{ lg: 12 }}>
97+
<Stack mb={8}>
98+
<section id='description' ref={ref}>
99+
<PageSubheading mb={8}>Open Rounds</PageSubheading>
100+
101+
<PageText>
102+
Grant rounds are time-limited funding opportunities focused on specific themes or
103+
areas of interest. Each round has its own application deadline and selection
104+
criteria. Browse active rounds below to find opportunities that match your
105+
expertise.
106+
</PageText>
107+
</section>
108+
</Stack>
109+
110+
<Stack spacing={10}>
111+
<section id='active-rounds' ref={ref2}>
112+
<PageSection mb={6}>Active Rounds</PageSection>
113+
114+
{activeRounds.length > 0 ? (
115+
<Stack spacing={6}>
116+
{activeRounds.map(round => (
117+
<RoundCard key={round.slug} round={round} />
118+
))}
119+
</Stack>
120+
) : (
121+
<PageText color='gray.600'>
122+
There are no active grant rounds at this time. Please check back later or
123+
explore our Wishlist and RFP opportunities.
124+
</PageText>
125+
)}
126+
</section>
127+
</Stack>
128+
</Box>
129+
</Flex>
130+
</Box>
131+
</>
132+
);
133+
};
134+
135+
export const getStaticProps: GetStaticProps<OpenRoundsProps> = async () => {
136+
const activeRounds = getActiveRounds();
137+
138+
return {
139+
props: {
140+
activeRounds
141+
},
142+
revalidate: 3600 // Revalidate every hour (3600 seconds)
143+
};
144+
};
145+
146+
export default OpenRounds;

0 commit comments

Comments
 (0)