diff --git a/.env.local.example b/.env.local.example
index 75cba2b7..04e85011 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -41,4 +41,7 @@ HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000
# listmonk
LISTMONK_API_USERNAME=
-LISTMONK_API_ACCESS_TOKEN=
\ No newline at end of file
+LISTMONK_API_ACCESS_TOKEN=
+
+# csat survey JWT secret
+CSAT_JWT_SECRET=
\ No newline at end of file
diff --git a/netlify.toml b/netlify.toml
index 6d099d8b..b06a80cc 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -37,10 +37,6 @@
from = "/*/support/"
to = "/about/"
force = true
-[[redirects]]
- from = "/*/wishlist/"
- to = "/about/"
- force = true
[[redirects]]
from = "/*/projects/"
to = "/about/who-we-support/"
@@ -146,10 +142,6 @@
from = "/support/"
to = "/about/"
force = true
-[[redirects]]
- from = "/wishlist/"
- to = "/about/"
- force = true
[[redirects]]
from = "/projects/"
to = "/about/who-we-support/"
diff --git a/package.json b/package.json
index 7dfbf53e..098250ca 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,8 @@
"framer-motion": "^11.0.5",
"google-spreadsheet": "^3.2.0",
"jsforce": "^1.11.0",
+ "jsonwebtoken": "^9.0.2",
+ "lucide-react": "^0.546.0",
"next": "^14.1.0",
"next-sitemap": "^2.5.7",
"papaparse": "^5.3.1",
@@ -48,6 +50,7 @@
"@types/formidable": "^2.0.4",
"@types/google-spreadsheet": "^3.1.5",
"@types/jsforce": "^1.9.37",
+ "@types/jsonwebtoken": "^9.0.10",
"@types/mailchimp__mailchimp_marketing": "^3.0.3",
"@types/node": "17.0.0",
"@types/papaparse": "^5.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 99b4f2f4..ff07cced 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -62,6 +62,12 @@ importers:
jsforce:
specifier: ^1.11.0
version: 1.11.1
+ jsonwebtoken:
+ specifier: ^9.0.2
+ version: 9.0.2
+ lucide-react:
+ specifier: ^0.546.0
+ version: 0.546.0(react@18.3.1)
next:
specifier: ^14.1.0
version: 14.2.32(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -108,6 +114,9 @@ importers:
'@types/jsforce':
specifier: ^1.9.37
version: 1.11.5
+ '@types/jsonwebtoken':
+ specifier: ^9.0.10
+ version: 9.0.10
'@types/mailchimp__mailchimp_marketing':
specifier: ^3.0.3
version: 3.0.21
@@ -738,6 +747,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/jsonwebtoken@9.0.10':
+ resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
'@types/lodash.mergewith@4.6.6':
resolution: {integrity: sha512-RY/8IaVENjG19rxTZu9Nukqh0W2UrYgmBj5sdns4hWRZaV8PqR7wIKHFKzvOTjo4zVRV7sVI+yFhAJql12Kfqg==}
@@ -2058,6 +2070,10 @@ packages:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
+ jsonwebtoken@9.0.2:
+ resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
+ engines: {node: '>=12', npm: '>=6'}
+
jsprim@1.4.2:
resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
engines: {node: '>=0.6.0'}
@@ -2066,9 +2082,15 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jwa@1.4.2:
+ resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
+
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+ jws@3.2.2:
+ resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+
jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
@@ -2104,12 +2126,33 @@ packages:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.mergewith@4.6.2:
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -2124,6 +2167,11 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
+ lucide-react@0.546.0:
+ resolution: {integrity: sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -3726,6 +3774,11 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/jsonwebtoken@9.0.10':
+ dependencies:
+ '@types/ms': 2.1.0
+ '@types/node': 17.0.0
+
'@types/lodash.mergewith@4.6.6':
dependencies:
'@types/lodash': 4.17.20
@@ -5242,6 +5295,19 @@ snapshots:
dependencies:
minimist: 1.2.8
+ jsonwebtoken@9.0.2:
+ dependencies:
+ jws: 3.2.2
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.7.2
+
jsprim@1.4.2:
dependencies:
assert-plus: 1.0.0
@@ -5256,12 +5322,23 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jwa@1.4.2:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
+ jws@3.2.2:
+ dependencies:
+ jwa: 1.4.2
+ safe-buffer: 5.2.1
+
jws@4.0.0:
dependencies:
jwa: 2.0.1
@@ -5294,10 +5371,24 @@ snapshots:
lodash.get@4.4.2: {}
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
lodash.merge@4.6.2: {}
lodash.mergewith@4.6.2: {}
+ lodash.once@4.1.1: {}
+
lodash@4.17.21: {}
loose-envify@1.4.0:
@@ -5310,6 +5401,10 @@ snapshots:
dependencies:
yallist: 4.0.0
+ lucide-react@0.546.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
math-intrinsics@1.1.0: {}
mdast-util-definitions@5.1.2:
diff --git a/public/images/ecosystem-support-logo.png b/public/images/ecosystem-support-logo.png
new file mode 100644
index 00000000..b27e4845
Binary files /dev/null and b/public/images/ecosystem-support-logo.png differ
diff --git a/public/images/funding-coordination-logo.png b/public/images/funding-coordination-logo.png
new file mode 100644
index 00000000..be24bc63
Binary files /dev/null and b/public/images/funding-coordination-logo.png differ
diff --git a/public/images/grant-management-logo.png b/public/images/grant-management-logo.png
new file mode 100644
index 00000000..f524dd87
Binary files /dev/null and b/public/images/grant-management-logo.png differ
diff --git a/public/images/launchpad-logo.png b/public/images/launchpad-logo.png
new file mode 100644
index 00000000..80a4749d
Binary files /dev/null and b/public/images/launchpad-logo.png differ
diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml
index c11c4d56..65ca7441 100644
--- a/public/sitemap-0.xml
+++ b/public/sitemap-0.xml
@@ -1,18 +1,44 @@
-https://esp.ethereum.foundation daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/about daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/about/how-we-support daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/about/who-we-support daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/academic-grants daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/academic-grants/apply daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants/office-hours daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants/office-hours/apply daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants/project-grants daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants/project-grants/apply daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants/small-grants daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/applicants/small-grants/apply daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/devcon-grants daily 0.7 2022-06-15T19:25:49.864Z
-https://esp.ethereum.foundation/devcon-grants/apply daily 0.7 2022-06-15T19:25:49.864Z
+https://esp.ethereum.foundation daily 0.7 2025-10-17T14:49:03.338Z
+https://esp.ethereum.foundation/10-year-anniversary daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/about daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/academic-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/academic-grants-2022 daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/academic-grants-2023 daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/academic-grants-2024 daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/account-abstraction-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/office-hours daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/office-hours/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/rfp daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/rfp/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/wishlist daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/wishlist/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/data-challenge-4844 daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/data-collection-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/devcon-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/devcon-grants/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/epf-application/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/epf-application/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/form-10yoe/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/form-10yoe/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/form-direct/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/form-direct/apply/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/layer-2-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/merge-data-challenge daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/pectra-pgr daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/pse-grants/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/pse-grants/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/pse-sponsorships/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/pse-sponsorships/thank-you daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/run-a-node-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/semaphore-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/zk-grants daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/wishlist/a1CVj000003rawTMAQ/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/wishlist/a1CVj000003t5VBMAY/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/wishlist/a1CVj0000043acXMAQ/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/rfp/a1CVj0000041nu1MAA/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/rfp/a1CVj0000042GRNMA2/apply daily 0.7 2025-10-17T14:49:03.339Z
+https://esp.ethereum.foundation/applicants/rfp/a1CVj0000042GftMAE/apply daily 0.7 2025-10-17T14:49:03.339Z
\ No newline at end of file
diff --git a/src/components/ButtonLink.tsx b/src/components/ButtonLink.tsx
index 1685956c..579de6a7 100644
--- a/src/components/ButtonLink.tsx
+++ b/src/components/ButtonLink.tsx
@@ -1,7 +1,6 @@
import { Box, Flex, Link } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import Image from 'next/image';
-import { FC } from 'react';
import { ImportantText } from './UI/headings';
@@ -9,17 +8,18 @@ import { useShadowAnimation } from '../hooks';
import planeVectorSVG from '../../public/images/plane-vector.svg';
-const MotionBox = motion(Box);
-const MotionFlex = motion(Flex);
+const MotionBox = motion.create(Box);
+const MotionFlex = motion.create(Flex);
interface Props {
label: string;
link: string;
width: string;
+ display?: string;
isApplyButton?: boolean;
}
-export const ButtonLink = ({ label, link, width, isApplyButton }: Props) => {
+export const ButtonLink = ({ label, link, width, display, isApplyButton }: Props) => {
const { shadowBoxControl, setButtonHovered } = useShadowAnimation();
return (
@@ -30,6 +30,7 @@ export const ButtonLink = ({ label, link, width, isApplyButton }: Props) => {
h='56px'
w={width}
position='absolute'
+ display={display || "flex"}
animate={shadowBoxControl}
/>
@@ -37,9 +38,11 @@ export const ButtonLink = ({ label, link, width, isApplyButton }: Props) => {
bg='brand.accent'
w={width}
py={4}
+ px={6}
justifyContent='center'
alignItems='center'
position='relative'
+ display={display || "flex"}
_hover={{ bg: 'brand.hover' }}
whileHover={{ x: -1.5, y: -1.5 }}
onMouseEnter={() => setButtonHovered(true)}
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index 8c9fa722..3b3d882e 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -26,7 +26,7 @@ import { CloseIcon, HamburgerIcon, NavLinkIcon } from './UI/icons';
import { selectedLink } from '../utils';
-import logoSVG from '../../public/images/esp-logo.svg';
+import GrantManagementLogo from '../../public/images/ecosystem-support-logo.png';
import { ESP_BLOG_URL, ETHEREUM_ORG_URL, HOME_URL, NAV_LINKS } from '../constants';
@@ -43,7 +43,7 @@ export const Nav: FC = () => {
sx={isOpen ? { transitionDelay: '0.15s', filter: 'brightness(10)' } : undefined}
zIndex={9999}
>
-
+
diff --git a/src/components/SubmitButton.tsx b/src/components/SubmitButton.tsx
index 8f23ce0d..6d845c39 100644
--- a/src/components/SubmitButton.tsx
+++ b/src/components/SubmitButton.tsx
@@ -17,8 +17,8 @@ interface Props {
text: string;
}
-const MotionBox = motion(Box);
-const MotionButton = motion(Button);
+const MotionBox = motion.create(Box);
+const MotionButton = motion.create(Button);
export const SubmitButton = ({
id = 'submit-application',
diff --git a/src/components/UI/HomeAboutCard.tsx b/src/components/UI/HomeAboutCard.tsx
index c33ff210..97961a44 100644
--- a/src/components/UI/HomeAboutCard.tsx
+++ b/src/components/UI/HomeAboutCard.tsx
@@ -16,9 +16,10 @@ interface Props {
title: string;
link: string;
children: ReactNode;
+ hideLink?: boolean;
}
-export const HomeAboutCard: FC = ({ bgGradient, img, title, link, children }) => {
+export const HomeAboutCard: FC = ({ bgGradient, img, title, link, children, hideLink }) => {
const { src, alt, width, height } = img;
return (
@@ -55,7 +56,7 @@ export const HomeAboutCard: FC = ({ bgGradient, img, title, link, childre
{children}
-
+
diff --git a/src/components/UI/HomepageHero.tsx b/src/components/UI/HomepageHero.tsx
index 5d458f5b..86ac33ad 100644
--- a/src/components/UI/HomepageHero.tsx
+++ b/src/components/UI/HomepageHero.tsx
@@ -30,11 +30,20 @@ export const HomepageHero: FC = () => {
textAlign='left'
mb={{ base: 6, md: 10 }}
>
- We provide support to the builders of the Ethereum ecosystem.
+ We provide support to builders creating public goods for the Ethereum ecosystem.
-
-
+
+
+
diff --git a/src/components/UI/common/SupportTeamCards.tsx b/src/components/UI/common/SupportTeamCards.tsx
new file mode 100644
index 00000000..e4a8d3cb
--- /dev/null
+++ b/src/components/UI/common/SupportTeamCards.tsx
@@ -0,0 +1,103 @@
+import {
+ Flex,
+ SimpleGrid,
+ Stack,
+} from '@chakra-ui/react';
+import Image from 'next/image';
+
+import { ButtonLink } from '../../ButtonLink';
+import { PageSection, PageText } from '../../UI';
+
+import communityOrganizersVector from '../../../../public/images/community-organizers-vector.svg';
+import otherGrantProgramsVector from '../../../../public/images/other-grant-programs-vector.svg';
+import researchersVector from '../../../../public/images/researchers-vector.svg';
+
+import { FOUNDER_SUCCESS_URL, ENTERPRISE_ACCELERATION_URL, ETHEREUM_EVERYWHERE_URL } from '../../../constants';
+
+const SupportTeamCards = () => {
+ const otherSupportCards = [
+ {
+ title: 'Founders',
+ description:
+ "Level up your founder journey with access to programs, mentorship, and visibility across the Ethereum ecosystem.",
+ ctaLabel: 'Founder Success',
+ href: FOUNDER_SUCCESS_URL,
+ icon: {
+ src: researchersVector,
+ alt: 'Illustration representing founder success support'
+ }
+ },
+ {
+ title: 'Businesses',
+ description:
+ 'Explore potential pathways and opportunities for businesses and enterprise looking to leverage Ethereum.',
+ ctaLabel: 'Enterprise Team',
+ href: ENTERPRISE_ACCELERATION_URL,
+ icon: {
+ src: otherGrantProgramsVector,
+ alt: 'Building illustration representing business growth'
+ }
+ },
+ {
+ title: 'Community Builders',
+ description:
+ 'Request support for organizing an event or launching a community initiative.',
+ ctaLabel: 'Ethereum Everywhere',
+ href: ETHEREUM_EVERYWHERE_URL,
+ icon: {
+ src: communityOrganizersVector,
+ alt: 'Community illustration representing collaboration'
+ }
+ }
+ ];
+
+ return (
+
+ Other EcoDev Teams
+
+
+ Looking for different support? Connect with the team that best matches your next step in the ecosystem.
+
+
+ {otherSupportCards.map((card) => (
+
+
+
+
+
+
+ {card.title}
+
+
+ {card.description}
+
+
+
+ ))}
+
+
+ )
+}
+
+export default SupportTeamCards;
\ No newline at end of file
diff --git a/src/components/forms/BaseGrantForm.tsx b/src/components/forms/BaseGrantForm.tsx
new file mode 100644
index 00000000..c6a7c33b
--- /dev/null
+++ b/src/components/forms/BaseGrantForm.tsx
@@ -0,0 +1,105 @@
+import { Stack, useToast } from '@chakra-ui/react';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { ReactNode } from 'react';
+import { FormProvider, useForm, FieldValues, SubmitHandler, DefaultValues } from 'react-hook-form';
+import { useRouter } from 'next/router';
+import * as z from 'zod';
+
+import { TOAST_OPTIONS } from '../../constants';
+import { FormConfig } from './schemas/BaseGrant';
+
+interface BaseGrantFormProps {
+ config: FormConfig;
+ schema: z.ZodTypeAny;
+ selectedItem: {
+ Id: string;
+ Name: string;
+ Description__c: string;
+ };
+ onSubmit: (data: T) => Promise;
+ defaultValues?: Partial;
+ children?: ReactNode;
+}
+
+export function BaseGrantForm({
+ config,
+ schema,
+ selectedItem,
+ onSubmit: submitFunction,
+ defaultValues = {},
+ children
+}: BaseGrantFormProps) {
+ const router = useRouter();
+ const toast = useToast();
+
+ const formDefaultValues: DefaultValues = {
+ [config.selectedItemIdField]: selectedItem.Id,
+ ...defaultValues
+ } as DefaultValues;
+
+ const methods = useForm({
+ resolver: zodResolver(schema),
+ mode: 'all',
+ shouldFocusError: true,
+ defaultValues: formDefaultValues
+ });
+
+ const {
+ handleSubmit,
+ reset,
+ formState: { errors }
+ } = methods;
+
+ const onSubmit: SubmitHandler = async data => {
+ return submitFunction(data)
+ .then(async res => {
+ if (res.ok) {
+ reset();
+
+ // Parse response to get applicationId and csatToken
+ try {
+ const responseData = await res.json();
+ const { applicationId, csatToken } = responseData;
+
+ // Navigate to thank you page with applicationId and CSAT token
+ if (applicationId && csatToken) {
+ router.push(
+ `${config.thankYouPageUrl}?applicationId=${applicationId}&csatToken=${csatToken}`
+ );
+ } else {
+ router.push(config.thankYouPageUrl);
+ }
+ } catch (error) {
+ // If parsing fails, navigate without applicationId
+ console.error('Error parsing response:', error);
+ router.push(config.thankYouPageUrl);
+ }
+ } else {
+ toast({
+ ...TOAST_OPTIONS,
+ title: 'Something went wrong while submitting, please try again.',
+ status: 'error'
+ });
+ throw new Error('Network response was not OK');
+ }
+ })
+ .catch(err => console.error('There has been a problem with your operation: ', err.message));
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/forms/CSATForm.tsx b/src/components/forms/CSATForm.tsx
new file mode 100644
index 00000000..3af42c4e
--- /dev/null
+++ b/src/components/forms/CSATForm.tsx
@@ -0,0 +1,168 @@
+import { FC, useState } from 'react';
+import {
+ Box,
+ Button,
+ Circle,
+ Flex,
+ Heading,
+ Textarea,
+ useToast,
+ VStack,
+ HStack,
+ BoxProps,
+ Center
+} from '@chakra-ui/react';
+import { useForm, FormProvider } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { CSATSchema, CSATData } from './schemas/CSAT';
+import { api } from './api';
+import { Captcha } from './fields/Captcha';
+import { TOAST_OPTIONS } from '../../constants';
+
+interface CSATFormProps extends BoxProps {
+ applicationId: string;
+ csatToken: string;
+}
+
+export const CSATForm: FC = ({ applicationId, csatToken, ...props }) => {
+ const [selectedRating, setSelectedRating] = useState(null);
+ const toast = useToast();
+
+ const methods = useForm({
+ resolver: zodResolver(CSATSchema),
+ mode: 'onSubmit',
+ defaultValues: {
+ applicationId,
+ csatToken,
+ csatRating: undefined,
+ csatComments: '',
+ captchaToken: ''
+ }
+ });
+
+ const {
+ handleSubmit,
+ register,
+ setValue,
+ formState: { isSubmitting, errors }
+ } = methods;
+
+ const handleRatingClick = (rating: number) => {
+ setSelectedRating(rating);
+ setValue('csatRating', rating, { shouldValidate: true });
+ };
+
+ const onSubmit = async (data: CSATData) => {
+ try {
+ const res = await api.csat.submit(data);
+
+ if (res.ok) {
+ toast({
+ ...TOAST_OPTIONS,
+ title: 'Thank you for your feedback!',
+ description: 'Your response has been recorded.',
+ status: 'success'
+ });
+ } else {
+ const errorData = await res.json();
+ toast({
+ ...TOAST_OPTIONS,
+ title: 'Unable to submit feedback',
+ description: errorData.error || 'Please try again later.',
+ status: 'error'
+ });
+ }
+ } catch (error) {
+ console.error('Error submitting CSAT:', error);
+ toast({
+ ...TOAST_OPTIONS,
+ title: 'Something went wrong',
+ description: 'Please try again later.',
+ status: 'error'
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/components/forms/DirectGrantForm.tsx b/src/components/forms/DirectGrantForm.tsx
new file mode 100644
index 00000000..62dadcf3
--- /dev/null
+++ b/src/components/forms/DirectGrantForm.tsx
@@ -0,0 +1,73 @@
+import { FC } from 'react';
+
+import { BaseGrantForm } from './BaseGrantForm';
+import {
+ ContactInformationSection,
+ ProjectOverviewSection,
+ ProjectDetailsSection,
+ AdditionalDetailsSection,
+ FormActions,
+ FormContainer
+} from './sections';
+import { DIRECT_GRANT_THANK_YOU_PAGE_URL } from '../../constants';
+import { UploadFile } from './fields';
+import { api } from './api';
+import { FormConfig } from './schemas/BaseGrant';
+import { DirectGrantSchema, DirectGrantData } from './schemas/DirectGrant';
+
+export const DirectGrantForm: FC = () => {
+ const directGrantFormConfig: FormConfig = {
+ formId: 'direct-grant-form',
+ submitApiEndpoint: 'direct-grant',
+ thankYouPageUrl: DIRECT_GRANT_THANK_YOU_PAGE_URL,
+ selectedItemIdField: 'selectedDirectGrantId',
+ selectedItemDisplayText: ''
+ };
+
+ const mockSelectedItem = {
+ Id: 'direct-grant',
+ Name: 'Direct Grant',
+ Description__c: 'Direct Grant Application'
+ };
+
+ return (
+
+ config={directGrantFormConfig}
+ schema={DirectGrantSchema}
+ selectedItem={mockSelectedItem}
+ onSubmit={api.directGrant.submit}
+ >
+
+
+
+ {/* Disable file upload since we need it to be at the bottom of the form and not required */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/forms/NewsletterSignup.tsx b/src/components/forms/NewsletterSignup.tsx
index 05d2c154..86800567 100644
--- a/src/components/forms/NewsletterSignup.tsx
+++ b/src/components/forms/NewsletterSignup.tsx
@@ -12,8 +12,8 @@ import { NewsletterFormData } from '../../types';
import { TOAST_OPTIONS } from '../../constants';
-const MotionBox = motion(Box);
-const MotionButton = motion(Button);
+const MotionBox = motion.create(Box);
+const MotionButton = motion.create(Button);
export const NewsletterSignup: FC = () => {
const {
diff --git a/src/components/forms/OfficeHoursForm.tsx b/src/components/forms/OfficeHoursForm.tsx
index 43805d80..cd442bb7 100644
--- a/src/components/forms/OfficeHoursForm.tsx
+++ b/src/components/forms/OfficeHoursForm.tsx
@@ -1,733 +1,63 @@
-import {
- Box,
- Center,
- Fade,
- Flex,
- FormControl,
- FormLabel,
- Input,
- Radio,
- RadioGroup,
- Stack,
- Textarea,
- useToast
-} from '@chakra-ui/react';
-import { Select } from 'chakra-react-select';
-import { FC, useState } from 'react';
-import { useForm, Controller, FormProvider } from 'react-hook-form';
-import { useRouter } from 'next/router';
-
-import { DropdownIndicator, PageText } from '../UI';
-import { SubmitButton } from '../SubmitButton';
-import { Captcha } from '.';
+import { FC } from 'react';
+import { BaseGrantForm } from './BaseGrantForm';
+import { AdditionalDetailsSection, ContactInformationSection } from './sections';
+import { OfficeHoursRequestSection } from './sections/OfficeHoursRequestSection';
+import { FormActions, FormContainer } from './sections/FormBlocks';
+import { OFFICE_HOURS_THANK_YOU_PAGE_URL } from '../../constants';
import { api } from './api';
-
-import { chakraStyles } from './selectStyles';
-import {
- ADVICE,
- HOW_DID_YOU_HEAR_ABOUT_ESP_OPTIONS,
- INDIVIDUAL,
- PROJECT_CATEGORY_OPTIONS,
- TEAM,
- PROJECT_FEEDBACK,
- COUNTRY_OPTIONS,
- TIMEZONE_OPTIONS
-} from './constants';
-import {
- OFFICE_HOURS_THANK_YOU_PAGE_URL,
- TOAST_OPTIONS,
- MAX_TEXT_AREA_LENGTH,
- MAX_TEXT_LENGTH
-} from '../../constants';
-
-import { IndividualOrTeam, OfficeHoursFormData, OfficeHoursRequest } from '../../types';
-import { containURL } from '../../utils';
+import { FormConfig } from './schemas/BaseGrant';
+import { OfficeHoursSchema, OfficeHoursData } from './schemas/OfficeHours';
export const OfficeHoursForm: FC = () => {
- const [individualOrTeam, setIndividualOrTeam] = useState(INDIVIDUAL);
- const [officeHoursRequest, setOfficeHoursRequest] =
- useState(PROJECT_FEEDBACK);
- const router = useRouter();
- const toast = useToast();
-
- const isTeam = individualOrTeam === TEAM;
- const isRequestingProjectFeedback = officeHoursRequest === PROJECT_FEEDBACK;
-
- const methods = useForm({
- mode: 'onBlur'
- });
- const {
- handleSubmit,
- register,
- trigger,
- control,
- formState: { errors, isValid, isSubmitting },
- reset
- } = methods;
+ const officeHoursFormConfig: FormConfig = {
+ formId: 'office-hours-form',
+ submitApiEndpoint: 'direct-grant', // This will be ignored, we're using a custom submit function
+ thankYouPageUrl: OFFICE_HOURS_THANK_YOU_PAGE_URL,
+ selectedItemIdField: 'selectedDirectGrantId', // Dummy value, not used
+ selectedItemDisplayText: ''
+ };
- const onSubmit = async (data: OfficeHoursFormData) => {
- return api.officeHours
- .submit(data)
- .then(res => {
- if (res.ok) {
- reset();
- router.push(OFFICE_HOURS_THANK_YOU_PAGE_URL);
- } else {
- toast({
- ...TOAST_OPTIONS,
- title: 'Something went wrong while submitting, please try again.',
- status: 'error'
- });
+ const mockSelectedItem = {
+ Id: 'office-hours',
+ Name: 'Office Hours',
+ Description__c: 'Office Hours Application'
+ };
- throw new Error('Network response was not OK');
- }
- })
- .catch(err => console.error('There has been a problem with your operation: ', err.message));
+ const defaultValues: Partial = {
+ officeHoursRequest: 'Project Feedback',
+ profileType: 'Individual',
+ repeatApplicant: false,
+ opportunityOutreachConsent: true
};
return (
-
+ config={officeHoursFormConfig}
+ schema={OfficeHoursSchema}
+ selectedItem={mockSelectedItem}
+ onSubmit={api.officeHours.submit}
+ defaultValues={defaultValues}
>
-
-
-
-
-
-
- First name
-
-
- !containURL(value)
- })}
- />
-
- {errors?.firstName?.type === 'required' && (
-
-
- First name is required.
-
-
- )}
- {errors?.firstName?.type === 'maxLength' && (
-
-
- First name cannot exceed 40 characters.
-
-
- )}
- {errors?.firstName?.type === 'validate' && (
-
-
- First name cannot contain a URL.
-
-
- )}
-
-
-
-
-
- Last name
-
-
- !containURL(value)
- })}
- />
-
- {errors?.lastName?.type === 'required' && (
-
-
- Last name is required.
-
-
- )}
- {errors?.lastName?.type === 'maxLength' && (
-
-
- Last name cannot exceed 80 characters.
-
-
- )}
- {errors?.lastName?.type === 'validate' && (
-
-
- Last name cannot contain a URL.
-
-
- )}
-
-
-
-
-
-
- Email
-
-
-
-
- {errors?.email?.type === 'required' && (
-
-
- Email is required.
-
-
- )}
-
-
- {/* If the component doesn't expose input's ref, we should use the Controller component, */}
- {/* which will take care of the registration process (https://react-hook-form.com/get-started#IntegratingwithUIlibraries) */}
- (
-
-
-
- Are you submitting on behalf of a team, or as an individual?
-
-
-
- {
- onChange(value);
- setIndividualOrTeam(value);
- }}
- value={value}
- fontSize='input'
- colorScheme='white'
- >
-
-
- {INDIVIDUAL}
-
-
-
- Team
-
-
-
-
- )}
- />
-
-
-
-
-
-
- Name of organization or entity
-
-
-
-
- Name of your team or entity you're submitting for. If your organization
- doesn't have a formal name, just try to describe it in a few words!
-
-
- !containURL(value)
- })}
- />
-
- {errors?.company?.type === 'required' && (
-
-
- Organization name is required.
-
-
- )}
- {errors?.company?.type === 'maxLength' && (
-
-
- Organization name cannot exceed {MAX_TEXT_LENGTH} characters.
-
-
- )}
- {errors?.company?.type === 'validate' && (
-
-
- Organization name cannot contain a URL.
-
-
- )}
-
-
-
-
- (
-
-
-
- Office Hours Request
-
-
-
-
- Choose from the options below. For feedback about whether your project is eligible
- for a grant, click the Project Feedback button.
-
-
- {
- onChange(value);
- setOfficeHoursRequest(value);
- }}
- value={value}
- fontSize='input'
- colorScheme='white'
- mt={3}
- >
-
-
- {ADVICE}
-
-
-
- {PROJECT_FEEDBACK}
-
-
-
-
- )}
- />
-
-
-
-
-
-
- Project name
-
-
-
-
-
- {errors?.projectName?.type === 'required' && (
-
-
- Project name is required.
-
-
- )}
- {errors?.projectName?.type === 'maxLength' && (
-
-
- Project name cannot exceed {MAX_TEXT_LENGTH} characters.
-
-
- )}
-
-
-
-
-
- What is your project about?
-
-
-
-
- Give us a short summary of what you are hoping to accomplish. Just a paragraph
- will do.
-
-
-
-
- {errors?.projectDescription?.type === 'required' && (
-
-
- Project description is required..
-
-
- )}
- {errors?.projectDescription?.type === 'maxLength' && (
-
-
- Project description cannot exceed {MAX_TEXT_AREA_LENGTH} characters.
-
-
- )}
-
-
-
-
-
- Where can we learn more?
-
-
-
-
- Please share links to any relevant Github repos, social media, websites, published
- work or professional profiles.
-
-
-
-
- {errors?.additionalInfo?.type === 'required' && (
-
-
- Some additional resources are required.
-
-
- )}
- {errors?.additionalInfo?.type === 'maxLength' && (
-
-
- Additional info cannot exceed {MAX_TEXT_AREA_LENGTH} characters.
-
-
- )}
-
-
- selected.value !== '' || !isRequestingProjectFeedback
- }}
- defaultValue={{ value: '', label: '' }}
- render={({ field: { onChange }, fieldState: { error } }) => (
-
-
-
- Project category
-
-
-
-
- Choose what category your project best fits into.
-
-
-
-
-
-
- {error && (
-
-
- Project category is required.
-
-
- )}
-
- )}
- />
-
-
-
- selected.value !== '' }}
- defaultValue={{ value: '', label: '' }}
- render={({ field: { onChange }, fieldState: { error } }) => (
-
-
-
- How did you hear about the Ecosystem Support Program?
-
-
-
-
-
- {error && (
-
-
- Referral source is required.
-
-
- )}
-
- )}
- />
-
-
-
-
- How are you hoping ESP can help?
-
-
-
-
- Please list any specific questions or details that would expedite the call.
-
-
-
-
- {errors?.otherReasonForMeeting?.type === 'required' && (
-
-
- Questions or details are required.
-
-
- )}
- {errors?.otherReasonForMeeting?.type === 'maxLength' && (
-
-
- Reason for meeting cannot exceed {MAX_TEXT_AREA_LENGTH} characters.
-
-
- )}
-
-
- selected.value !== '' }}
- defaultValue={{ value: '', label: '' }}
- render={({ field: { onChange }, fieldState: { error } }) => (
-
-
-
- Country
-
-
-
- {
- onChange(value);
- trigger('country');
- }}
- components={{ DropdownIndicator }}
- placeholder='Select'
- closeMenuOnSelect={true}
- selectedOptionColor='brand.option'
- chakraStyles={chakraStyles}
- />
-
- {error && (
-
-
- Country is required.
-
-
- )}
-
- )}
- />
-
- selected.value !== '' }}
- defaultValue={{ value: '', label: '' }}
- render={({ field: { onChange }, fieldState: { error } }) => (
-
-
-
- Your time zone
-
-
-
- {
- onChange(value);
- trigger('timezone');
- }}
- components={{ DropdownIndicator }}
- placeholder='Select'
- closeMenuOnSelect={true}
- selectedOptionColor='brand.option'
- chakraStyles={chakraStyles}
- />
-
- {error && (
-
-
- Time zone is required.
-
-
- )}
-
- )}
- />
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/components/forms/RFPForm.tsx b/src/components/forms/RFPForm.tsx
new file mode 100644
index 00000000..3f0f054d
--- /dev/null
+++ b/src/components/forms/RFPForm.tsx
@@ -0,0 +1,59 @@
+import { FC } from 'react';
+
+import { BaseGrantForm } from './BaseGrantForm';
+import { RFP_THANK_YOU_PAGE_URL } from '../../constants';
+import { api } from './api';
+import { FormConfig } from './schemas/BaseGrant';
+import { RFPSchema, RFPItem, RFPData } from './schemas/RFP';
+import {
+ ContactInformationSection,
+ FormContainer,
+ ProjectOverviewSection,
+ SelectedItemDisplay,
+ AdditionalDetailsSection,
+ FormActions
+} from './sections';
+
+interface RFPFormProps {
+ rfpItem: RFPItem;
+}
+
+const rfpFormConfig: FormConfig = {
+ formId: 'rfp-form',
+ submitApiEndpoint: 'rfp',
+ thankYouPageUrl: RFP_THANK_YOU_PAGE_URL,
+ selectedItemIdField: 'selectedRFPId',
+ selectedItemDisplayText: 'Selected RFP'
+};
+
+export const RFPForm: FC = ({ rfpItem }) => {
+ return (
+
+ config={rfpFormConfig}
+ schema={RFPSchema}
+ selectedItem={rfpItem}
+ onSubmit={api.rfp.submit}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/forms/RFPSelection.tsx b/src/components/forms/RFPSelection.tsx
new file mode 100644
index 00000000..fba5a1b4
--- /dev/null
+++ b/src/components/forms/RFPSelection.tsx
@@ -0,0 +1,229 @@
+import {
+ Box,
+ chakra,
+ Flex,
+ Grid,
+ GridItem,
+ Heading,
+ Icon,
+ Link,
+ List,
+ ListItem,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
+ Stack,
+ Text,
+ Wrap,
+ WrapItem,
+ Tag
+} from '@chakra-ui/react';
+import { FC, useMemo, useState, useEffect } from 'react';
+import { LayoutGrid, Rows3 } from 'lucide-react';
+
+import { RFPItem } from './schemas/RFP';
+import { SelectArrowIcon } from '../UI/icons';
+
+const Button = chakra('button');
+
+interface RFPSelectionProps {
+ rfpItems: RFPItem[];
+ paramTags?: string[];
+}
+
+export const RFPSelection: FC = ({ rfpItems, paramTags }) => {
+ const [selectedTags, setSelectedTags] = useState(paramTags ? paramTags : []);
+ const [displayFormat, setDisplayFormat] = useState<'grid' | 'table'>('grid');
+
+ const tagOptions = useMemo(() => {
+ return Array.from(new Set(rfpItems.reduce((acc, item) => {
+ item.Tags__c?.split(';').forEach(tag => {
+ acc.push(tag.trim());
+ });
+ return acc;
+ }, [] as string[]).filter(Boolean)));
+ }, [rfpItems]);
+
+ // Sync selectedTags with paramTags when paramTags changes
+ useEffect(() => {
+ if (paramTags) {
+ // Validate paramTags against available tagOptions
+ const validTags = paramTags.filter(tag => tagOptions.includes(tag));
+ setSelectedTags(validTags);
+ }
+ }, [paramTags, tagOptions]);
+
+ const handleToggleTag = (tag: string) => {
+ setSelectedTags(prev =>
+ prev.includes(tag)
+ ? prev.filter(t => t !== tag)
+ : [...prev, tag]
+ );
+ };
+
+ const handleClearAllTags = () => {
+ setSelectedTags([]);
+ };
+
+ if (rfpItems.length === 0) {
+ return (
+
+
+ No RFP Items Available
+
+ There are currently no active Request for Proposals available for application.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {selectedTags.length > 0 ? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected` : 'Filter by tags'}
+
+
+
+
+ {selectedTags.length > 0 && (
+
+ Clear all tags
+
+ )}
+ {tagOptions.map(tag => (
+ handleToggleTag(tag)}
+ bg={selectedTags.includes(tag) ? 'orange.50' : 'white'}
+ color={selectedTags.includes(tag) ? 'orange.600' : 'inherit'}
+ >
+ {selectedTags.includes(tag) && '✓ '}{tag}
+
+ ))}
+
+
+
+ setDisplayFormat('grid')} _hover={{ color: 'brand.hover' }} />
+ setDisplayFormat('table')} _hover={{ color: 'brand.hover' }} />
+
+
+
+ {selectedTags.length > 0 && (
+
+
+ Selected tags:
+
+
+ {selectedTags.map(tag => (
+
+ handleToggleTag(tag)}
+ _hover={{ opacity: 0.8 }}
+ >
+ {tag} ×
+
+
+ ))}
+
+
+ )}
+
+ {displayFormat === 'grid' && (
+
+ {rfpItems.filter(item =>
+ selectedTags.length === 0
+ ? true
+ : selectedTags.some(tag => item.Tags__c?.includes(tag))
+ ).map(item => (
+
+
+
+
+ {item.Name}
+
+
+ {item.Category__c && (
+
+ {item.Category__c}
+
+ )}
+
+
+ {item.Description__c}
+
+
+
+
+ ))}
+
+ )}
+
+ {displayFormat === 'table' && (
+
+ {rfpItems.filter(item =>
+ selectedTags.length === 0
+ ? true
+ : selectedTags.some(tag => item.Tags__c?.includes(tag))
+ ).map(item => (
+
+
+
+ {item.Name}
+
+ {item.Tags__c?.split(';').map(tag => tag.trim()).map(tag => {tag} )}
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/components/forms/WishlistForm.tsx b/src/components/forms/WishlistForm.tsx
new file mode 100644
index 00000000..df1254e3
--- /dev/null
+++ b/src/components/forms/WishlistForm.tsx
@@ -0,0 +1,75 @@
+import { FC } from 'react';
+
+import { BaseGrantForm } from './BaseGrantForm';
+import {
+ ContactInformationSection,
+ ProjectOverviewSection,
+ ProjectDetailsSection,
+ AdditionalDetailsSection,
+ SelectedItemDisplay,
+ FormActions,
+ FormContainer
+} from './sections';
+import { WISHLIST_THANK_YOU_PAGE_URL } from '../../constants';
+import { UploadFile } from './fields';
+import { api } from './api';
+import { FormConfig } from './schemas/BaseGrant';
+import { WishlistSchema, WishlistItem, WishlistData } from './schemas/Wishlist';
+
+interface WishlistFormProps {
+ wishlistItem: WishlistItem;
+}
+
+const wishlistFormConfig: FormConfig = {
+ formId: 'wishlist-form',
+ submitApiEndpoint: 'wishlist',
+ thankYouPageUrl: WISHLIST_THANK_YOU_PAGE_URL,
+ selectedItemIdField: 'selectedWishlistId',
+ selectedItemDisplayText: 'Selected Wishlist'
+};
+
+export const WishlistForm: FC = ({ wishlistItem }) => {
+ return (
+
+ config={wishlistFormConfig}
+ schema={WishlistSchema}
+ selectedItem={wishlistItem}
+ onSubmit={api.wishlist.submit}
+ >
+
+
+
+
+
+ {/* Disable file upload since we need it to be at the bottom of the form and not required */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/forms/WishlistSelection.tsx b/src/components/forms/WishlistSelection.tsx
new file mode 100644
index 00000000..1006f56a
--- /dev/null
+++ b/src/components/forms/WishlistSelection.tsx
@@ -0,0 +1,228 @@
+import {
+ Box,
+ chakra,
+ Flex,
+ Grid,
+ GridItem,
+ Heading,
+ Icon,
+ Link,
+ List,
+ ListItem,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
+ Stack,
+ Text,
+ Wrap,
+ WrapItem,
+ Tag,
+} from '@chakra-ui/react';
+import { FC, useMemo, useState, useEffect } from 'react';
+import { LayoutGrid, Rows3 } from 'lucide-react';
+
+import { WishlistItem } from './schemas/Wishlist';
+import { SelectArrowIcon } from '../UI/icons';
+
+const Button = chakra('button');
+
+interface WishlistSelectionProps {
+ wishlistItems: WishlistItem[];
+ paramTags?: string[];
+}
+
+export const WishlistSelection: FC = ({ wishlistItems, paramTags }) => {
+ const [selectedTags, setSelectedTags] = useState(paramTags ? paramTags : []);
+ const [displayFormat, setDisplayFormat] = useState<'grid' | 'table'>('grid');
+
+ const tagOptions = useMemo(() => {
+ return Array.from(new Set(wishlistItems.reduce((acc, item) => {
+ item.Tags__c?.split(';').forEach(tag => {
+ acc.push(tag.trim());
+ });
+ return acc;
+ }, [] as string[]).filter(Boolean)));
+ }, [wishlistItems]);
+
+ // Sync selectedTags with paramTags when paramTags changes
+ useEffect(() => {
+ if (paramTags) {
+ // Validate paramTags against available tagOptions
+ const validTags = paramTags.filter(tag => tagOptions.includes(tag));
+ setSelectedTags(validTags);
+ }
+ }, [paramTags, tagOptions]);
+
+ const handleToggleTag = (tag: string) => {
+ setSelectedTags(prev =>
+ prev.includes(tag)
+ ? prev.filter(t => t !== tag)
+ : [...prev, tag]
+ );
+ };
+
+ const handleClearAllTags = () => {
+ setSelectedTags([]);
+ };
+
+ if (wishlistItems.length === 0) {
+ return (
+
+
+ No Wishlist Available
+
+ There are currently no active wishlists available for application.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {selectedTags.length > 0 ? `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected` : 'Filter by tags'}
+
+
+
+
+ {selectedTags.length > 0 && (
+
+ Clear all tags
+
+ )}
+ {tagOptions.map(tag => (
+ handleToggleTag(tag)}
+ bg={selectedTags.includes(tag) ? 'orange.50' : 'white'}
+ color={selectedTags.includes(tag) ? 'orange.600' : 'inherit'}
+ >
+ {selectedTags.includes(tag) && '✓ '}{tag}
+
+ ))}
+
+
+
+ setDisplayFormat('grid')} _hover={{ color: 'brand.hover' }} />
+ setDisplayFormat('table')} _hover={{ color: 'brand.hover' }} />
+
+
+
+ {selectedTags.length > 0 && (
+
+
+ Selected tags:
+
+
+ {selectedTags.map(tag => (
+
+ handleToggleTag(tag)}
+ _hover={{ opacity: 0.8 }}
+ >
+ {tag} ×
+
+
+ ))}
+
+
+ )}
+
+ {displayFormat === 'grid' && (
+
+ {wishlistItems.filter(item =>
+ selectedTags.length === 0
+ ? true
+ : selectedTags.some(tag => item.Tags__c?.includes(tag))
+ ).map(item => (
+
+
+
+
+ {item.Name}
+
+
+ {item.Category__c && (
+
+ {item.Category__c}
+
+ )}
+
+
+ {item.Description__c}
+
+
+
+
+ ))}
+
+ )}
+ {displayFormat === 'table' && (
+
+ {wishlistItems.filter(item =>
+ selectedTags.length === 0
+ ? true
+ : selectedTags.some(tag => item.Tags__c?.includes(tag))
+ ).map(item => (
+
+
+
+ {item.Name}
+
+ {item.Tags__c?.split(';').map(tag => tag.trim()).map(tag => {tag} )}
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/components/forms/api.ts b/src/components/forms/api.ts
index 4f88899a..033f8cfb 100644
--- a/src/components/forms/api.ts
+++ b/src/components/forms/api.ts
@@ -3,7 +3,6 @@ import {
EcodevGrantsFormData,
GranteeFinanceFormData,
NewsletterFormData,
- OfficeHoursFormData,
PSESponsorshipsFormData,
ProjectGrantsFormData,
SmallGrantsFormData
@@ -25,7 +24,11 @@ import {
API_EPF_APPLICATION,
API_PSE_APPLICATION,
API_ACADEMIC_GRANTS,
- API_TEN_YEAR_ANNIVERSARY
+ API_TEN_YEAR_ANNIVERSARY,
+ API_WISHLIST,
+ API_RFP,
+ API_DIRECT_GRANT,
+ API_CSAT
} from './constants';
import type { EPFData } from './schemas/EPFApplication';
@@ -34,6 +37,11 @@ import type { AcademicGrantsData } from './schemas/AcademicGrants';
import type { PectraPGRData } from './schemas/PectraPGR';
import type { DestinoDevconnectData } from './schemas/DestinoDevconnect';
import type { TenYearAnniversaryData } from './schemas/TenYearAnniversary';
+import type { WishlistData } from './schemas/Wishlist';
+import type { RFPData } from './schemas/RFP';
+import type { DirectGrantData } from './schemas/DirectGrant';
+import type { OfficeHoursData } from './schemas/OfficeHours';
+import type { CSATData } from './schemas/CSAT';
const methodOptions = {
method: 'POST',
@@ -42,18 +50,18 @@ const methodOptions = {
export const api = {
officeHours: {
- submit: (data: OfficeHoursFormData) => {
+ submit: (data: OfficeHoursData) => {
+ const curatedData: { [key: string]: any } = {
+ ...data,
+ // Company is a required field in SF, we're using the Name as default value if no company provided
+ company: data.company || `${data.firstName} ${data.lastName}`
+ };
+
+ const formData = createFormData(curatedData);
+
const officeHoursRequestOptions: RequestInit = {
- ...methodOptions,
- body: JSON.stringify({
- ...data,
- // Company is a required field in SF, we're using the Name as default value if no company provided
- company: data.company === '' ? `${data.firstName} ${data.lastName}` : data.company,
- projectCategory: data.projectCategory.value,
- howDidYouHearAboutESP: data.howDidYouHearAboutESP.value,
- country: data.country.value,
- timezone: data.timezone.value
- })
+ method: 'POST',
+ body: formData
};
return fetch(API_OFFICE_HOURS, officeHoursRequestOptions);
@@ -270,5 +278,66 @@ export const api = {
body: JSON.stringify(data)
});
}
+ },
+ wishlist: {
+ submit: (data: WishlistData) => {
+ const curatedData: { [key: string]: any } = {
+ ...data,
+ company: data.company || `${data.firstName} ${data.lastName}`
+ };
+
+ const formData = createFormData(curatedData);
+
+ const wishlistRequestOptions: RequestInit = {
+ method: 'POST',
+ body: formData
+ };
+
+ return fetch(API_WISHLIST, wishlistRequestOptions);
+ }
+ },
+ rfp: {
+ submit: (data: RFPData) => {
+ const curatedData: { [key: string]: any } = {
+ ...data,
+ company: data.company || `${data.firstName} ${data.lastName}`
+ };
+
+ const formData = createFormData(curatedData);
+
+ const rfpRequestOptions: RequestInit = {
+ method: 'POST',
+ body: formData
+ };
+
+ return fetch(API_RFP, rfpRequestOptions);
+ }
+ },
+ directGrant: {
+ submit: (data: DirectGrantData) => {
+ const curatedData: { [key: string]: any } = {
+ ...data,
+ company: data.company || `${data.firstName} ${data.lastName}`
+ };
+
+ const formData = createFormData(curatedData);
+
+ const directGrantRequestOptions: RequestInit = {
+ method: 'POST',
+ body: formData
+ };
+
+ return fetch(API_DIRECT_GRANT, directGrantRequestOptions);
+ }
+ },
+ csat: {
+ submit: (data: CSATData) => {
+ const csatRequestOptions: RequestInit = {
+ ...methodOptions,
+ body: JSON.stringify(data)
+ };
+
+ return fetch(API_CSAT, csatRequestOptions);
+ }
}
};
diff --git a/src/components/forms/constants.ts b/src/components/forms/constants.ts
index 40bef2e8..931f6485 100644
--- a/src/components/forms/constants.ts
+++ b/src/components/forms/constants.ts
@@ -1687,6 +1687,9 @@ export const COMMITMENT_OPTIONS = [
// API routes
export const API_OFFICE_HOURS = '/api/office-hours';
+export const API_WISHLIST = '/api/wishlist';
+export const API_RFP = '/api/rfp';
+export const API_DIRECT_GRANT = '/api/direct-grant';
export const API_PROJECT_GRANTS = '/api/project-grants';
export const API_SMALL_GRANTS_PROJECT = '/api/small-grants/project';
export const API_SMALL_GRANTS_EVENT = '/api/small-grants/event';
@@ -1701,6 +1704,7 @@ export const API_NEWSLETTER_SIGNUP_URL = '/api/newsletter-signup';
export const API_PSE_APPLICATION = '/api/pse-grants';
export const API_PECTRA_PGR = '/api/pectra-pgr';
export const API_TEN_YEAR_ANNIVERSARY = '/api/10-year-anniversary';
+export const API_CSAT = '/api/csat';
export const CATEGORY_OPTIONS = [
{ value: 'Community Event', label: 'Community Event' },
@@ -1732,3 +1736,42 @@ export const REFERRAL_SOURCE_OPTIONS = [
{ value: 'Social Media', label: 'Social Media' },
{ value: 'Other', label: 'Other' }
];
+
+export const PROFILE_TYPE_OPTIONS = [
+ { value: 'Individual', label: 'Individual' },
+ { value: 'Team', label: 'Team' },
+ { value: 'Company', label: 'Company' },
+ { value: 'Organization', label: 'Organization' },
+ { value: 'University', label: 'University' },
+ { value: 'Consortium of Universities', label: 'Consortium of Universities' },
+ { value: 'Research Center', label: 'Research Center' },
+ { value: 'Other', label: 'Other' }
+];
+
+export const DOMAIN_OPTIONS = [
+ { value: 'Application layer', label: 'Application layer' },
+ { value: 'Cryptography', label: 'Cryptography' },
+ { value: 'DAOs/Governance', label: 'DAOs/Governance' },
+ { value: 'Decentralized Identity', label: 'Decentralized Identity' },
+ { value: 'DeFi', label: 'DeFi' },
+ { value: 'Economics', label: 'Economics' },
+ { value: 'Ethereum Protocol', label: 'Ethereum Protocol' },
+ { value: 'Government', label: 'Government' },
+ { value: 'Layer 2', label: 'Layer 2' },
+ { value: 'NFTs / Digital Art', label: 'NFTs / Digital Art' },
+ { value: 'Nodes and Clients', label: 'Nodes and Clients' },
+ { value: 'Privacy', label: 'Privacy' },
+ { value: 'Security', label: 'Security' },
+ { value: 'Society and Regulatory', label: 'Society and Regulatory' },
+ { value: 'UX/UI', label: 'UX/UI' },
+ { value: 'Zero-knowledge Proofs', label: 'Zero-knowledge Proofs' },
+ { value: 'Other', label: 'Other' }
+];
+
+export const OUTPUT_OPTIONS = [
+ { value: 'Application', label: 'Application' },
+ { value: 'Dashboard', label: 'Dashboard' },
+ { value: 'Developer tooling', label: 'Developer tooling' },
+ { value: 'Ecosystem development', label: 'Ecosystem development' },
+ { value: 'Research', label: 'Research' }
+];
diff --git a/src/components/forms/fields/Captcha.tsx b/src/components/forms/fields/Captcha.tsx
index ad7530b0..8a5027c8 100644
--- a/src/components/forms/fields/Captcha.tsx
+++ b/src/components/forms/fields/Captcha.tsx
@@ -7,12 +7,20 @@ import { PageText } from '../../UI';
export const Captcha: FC = () => {
const captchaRef = useRef(null);
const { register, setValue, formState, resetField } = useFormContext();
- const { errors } = formState;
+ const { errors, isSubmitSuccessful, isSubmitting } = formState;
useEffect(() => {
register('captchaToken', { required: true });
}, [register]);
+ // Automatically reset captcha after form submission
+ useEffect(() => {
+ if (isSubmitSuccessful && !isSubmitting) {
+ captchaRef.current?.resetCaptcha();
+ resetField('captchaToken');
+ }
+ }, [isSubmitSuccessful, isSubmitting, resetField]);
+
const onVerify = useCallback(
(token: string) => {
// force a validate to update the `isValid` flag from the formState
diff --git a/src/components/forms/fields/TextAreaField.tsx b/src/components/forms/fields/TextAreaField.tsx
index 29640752..87261e4e 100644
--- a/src/components/forms/fields/TextAreaField.tsx
+++ b/src/components/forms/fields/TextAreaField.tsx
@@ -1,12 +1,14 @@
import React, { FC } from 'react';
-import { Textarea } from '@chakra-ui/react';
+import { Textarea, TextareaProps } from '@chakra-ui/react';
import { useFormContext } from 'react-hook-form';
import { Field, type Props as FieldProps } from './Field';
-interface Props extends Omit {}
+interface Props extends Omit {
+ textareaProps?: Omit;
+}
-export const TextAreaField: FC = ({ id, isDisabled, ...rest }) => {
+export const TextAreaField: FC = ({ id, isDisabled, textareaProps, ...rest }) => {
const {
register,
formState: { errors }
@@ -26,6 +28,7 @@ export const TextAreaField: FC = ({ id, isDisabled, ...rest }) => {
h='150px'
mt={3}
{...register(id)}
+ {...textareaProps}
/>
);
diff --git a/src/components/forms/fields/UploadFile.tsx b/src/components/forms/fields/UploadFile.tsx
index 2efa85c4..f6066fac 100644
--- a/src/components/forms/fields/UploadFile.tsx
+++ b/src/components/forms/fields/UploadFile.tsx
@@ -94,7 +94,7 @@ export const UploadFile: FC = ({
render={({ fieldState: { error } }) => (
-
+
!containURL(value),
+ 'First name cannot contain a URL'
+ ),
+ lastName: stringFieldSchema('Last name', { min: 1, max: 20 }).refine(
+ value => !containURL(value),
+ 'Last name cannot contain a URL'
+ ),
+ email: z.string().email({ message: 'Invalid email address' }),
+ company: stringFieldSchema('Company', { min: 1, max: 50 }),
+ profileType: z.enum([
+ 'Individual',
+ 'Team',
+ 'Company',
+ 'Organization',
+ 'University',
+ 'Consortium of Universities',
+ 'Research Center',
+ 'Other'
+ ]),
+ otherProfileType: stringFieldSchema('Other profile type', { max: 50 }).optional(),
+ alternativeContact: stringFieldSchema('Alternative contact', { max: 50 }).optional(),
+ website: stringFieldSchema('Website', { max: MAX_TEXT_LENGTH })
+ .url({ message: 'Invalid URL' })
+ .optional()
+ .or(z.literal('')),
+ country: stringFieldSchema('Country', { min: 1, max: 2 }), // 2 character country code
+ timezone: stringFieldSchema('Time zone', { min: 1 })
+};
+
+const projectOverviewSchema = {
+ projectName: stringFieldSchema('Project name', { min: 1, max: 80 }),
+ projectSummary: stringFieldSchema('Project summary', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ fileUpload: z
+ .any()
+ .refine(file => !!file, 'PDF proposal is required')
+ .refine(file => (file?.size ?? 0) <= MAX_WISHLIST_FILE_SIZE, 'Max file size is 4MB.')
+ .refine(file => (file?.type || file?.mimetype) === 'application/pdf', 'File must be a PDF'),
+ projectRepo: stringFieldSchema('Project repo', { max: MAX_TEXT_LENGTH })
+ .url({ message: 'Invalid URL' })
+ .or(z.literal('')),
+ domain: z.enum([
+ 'Application layer',
+ 'Cryptography',
+ 'DAOs/Governance',
+ 'Decentralized Identity',
+ 'DeFi',
+ 'Economics',
+ 'Ethereum Protocol',
+ 'Government',
+ 'Layer 2',
+ 'NFTs / Digital Art',
+ 'Nodes and Clients',
+ 'Privacy',
+ 'Security',
+ 'Society and Regulatory',
+ 'UX/UI',
+ 'Zero-knowledge Proofs',
+ 'Other'
+ ]),
+ output: z.enum([
+ 'Application',
+ 'Dashboard',
+ 'Developer tooling',
+ 'Ecosystem development',
+ 'Research'
+ ]),
+ budgetRequest: z.coerce
+ .number({
+ required_error: 'Budget request is required',
+ invalid_type_error: 'Please enter a valid number'
+ })
+ .positive('Budget request must be a positive number'),
+ currency: stringFieldSchema('Currency', { min: 1 })
+};
+
+const projectDetailsSchema = {
+ projectStructure: stringFieldSchema('Project structure', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ sustainabilityPlan: stringFieldSchema('Sustainability plan', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ funding: stringFieldSchema('Other funding', {
+ min: CUSTOM_MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ problemBeingSolved: stringFieldSchema('Problem being solved', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ measuredImpact: stringFieldSchema('Measured impact', {
+ min: CUSTOM_MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ successMetrics: stringFieldSchema('Success metrics', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ ecosystemFit: stringFieldSchema('Ecosystem fit', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ communityFeedback: stringFieldSchema('Community feedback', {
+ min: CUSTOM_MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ openSourceLicense: stringFieldSchema('Open source license', {
+ min: 1,
+ max: MAX_TEXT_LENGTH
+ })
+};
+
+const additionalDetailsSchema = {
+ repeatApplicant: z.boolean().default(false),
+ referral: stringFieldSchema('Referral', { min: 1, max: MAX_TEXT_LENGTH }),
+ additionalInfo: stringFieldSchema('Additional information', { max: MAX_TEXT_LENGTH }).optional(),
+ opportunityOutreachConsent: z.boolean().default(true)
+};
+
+const requiredSchema = {
+ captchaToken: stringFieldSchema('Captcha', { min: 1 })
+};
+
+export const BaseGrantSchema = z.object({
+ ...contactInformationSchema,
+ ...projectOverviewSchema,
+ ...projectDetailsSchema,
+ ...additionalDetailsSchema,
+ ...requiredSchema
+});
+
+export {
+ contactInformationSchema,
+ projectOverviewSchema,
+ projectDetailsSchema,
+ additionalDetailsSchema,
+ requiredSchema
+};
+
+export type BaseGrantData = z.infer;
+
+export interface FormConfig {
+ formId: string;
+ submitApiEndpoint: 'wishlist' | 'rfp' | 'direct-grant';
+ thankYouPageUrl: string;
+ selectedItemIdField: 'selectedWishlistId' | 'selectedRFPId' | 'selectedDirectGrantId';
+ selectedItemDisplayText: string;
+}
diff --git a/src/components/forms/schemas/CSAT.ts b/src/components/forms/schemas/CSAT.ts
new file mode 100644
index 00000000..b47f46df
--- /dev/null
+++ b/src/components/forms/schemas/CSAT.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { stringFieldSchema } from './utils';
+import { MAX_TEXT_AREA_LENGTH } from '../../../constants';
+
+export const CSATSchema = z.object({
+ // Salesforce Application ID reference
+ applicationId: stringFieldSchema('Application ID', { min: 1 }),
+
+ // CSAT Token for security
+ csatToken: stringFieldSchema('CSAT Token', { min: 1 }),
+
+ // CSAT Rating (1-5)
+ csatRating: z.coerce
+ .number({
+ required_error: 'Please select a satisfaction rating',
+ invalid_type_error: 'Please select a valid rating'
+ })
+ .int('Rating must be a whole number')
+ .min(1, 'Rating must be between 1 and 5')
+ .max(5, 'Rating must be between 1 and 5'),
+
+ // CSAT Comments (optional)
+ csatComments: stringFieldSchema('Comments', { max: MAX_TEXT_AREA_LENGTH }).optional(),
+
+ // Captcha token
+ captchaToken: stringFieldSchema('Captcha', { min: 1 })
+});
+
+export type CSATData = z.infer;
diff --git a/src/components/forms/schemas/DirectGrant.ts b/src/components/forms/schemas/DirectGrant.ts
new file mode 100644
index 00000000..0969cee2
--- /dev/null
+++ b/src/components/forms/schemas/DirectGrant.ts
@@ -0,0 +1,37 @@
+import {
+ contactInformationSchema,
+ projectOverviewSchema,
+ projectDetailsSchema,
+ additionalDetailsSchema,
+ requiredSchema
+} from './BaseGrant';
+import { z } from 'zod';
+import {
+ MAX_TEXT_AREA_LENGTH,
+ MIN_TEXT_AREA_LENGTH,
+ MAX_WISHLIST_FILE_SIZE
+} from '../../../constants';
+import { stringFieldSchema } from './utils';
+
+export const DirectGrantSchema = z.object({
+ ...contactInformationSchema,
+ ...projectOverviewSchema,
+ ...projectDetailsSchema,
+ ...additionalDetailsSchema,
+ ...requiredSchema,
+ applicantProfile: stringFieldSchema('Applicant profile', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ // File upload field
+ fileUpload: z
+ .any()
+ .optional()
+ .refine(file => !file || (file?.size ?? 0) <= MAX_WISHLIST_FILE_SIZE, 'Max file size is 4MB.')
+ .refine(
+ file => !file || (file?.type || file?.mimetype) === 'application/pdf',
+ 'File must be a PDF'
+ )
+});
+
+export type DirectGrantData = z.infer;
diff --git a/src/components/forms/schemas/OfficeHours.ts b/src/components/forms/schemas/OfficeHours.ts
new file mode 100644
index 00000000..de90c3d7
--- /dev/null
+++ b/src/components/forms/schemas/OfficeHours.ts
@@ -0,0 +1,117 @@
+import { z } from 'zod';
+import { stringFieldSchema } from './utils';
+import { containURL } from '../../../utils';
+import { MAX_TEXT_AREA_LENGTH, MAX_TEXT_LENGTH, MAX_WISHLIST_FILE_SIZE } from '../../../constants';
+
+const baseContactSchema = {
+ firstName: stringFieldSchema('First name', { min: 1, max: 20 }).refine(
+ value => !containURL(value),
+ 'First name cannot contain a URL'
+ ),
+ lastName: stringFieldSchema('Last name', { min: 1, max: 20 }).refine(
+ value => !containURL(value),
+ 'Last name cannot contain a URL'
+ ),
+ email: z.string().email({ message: 'Invalid email address' }),
+ company: stringFieldSchema('Company', { max: 50 }).optional().or(z.literal('')),
+ profileType: z.enum([
+ 'Individual',
+ 'Team',
+ 'Company',
+ 'Organization',
+ 'University',
+ 'Consortium of Universities',
+ 'Research Center',
+ 'Other'
+ ]),
+ otherProfileType: stringFieldSchema('Other profile type', { max: 50 }).optional(),
+ alternativeContact: stringFieldSchema('Alternative contact', { max: 50 }).optional(),
+ country: stringFieldSchema('Country', { min: 1, max: 2 }), // 2 character country code
+ timezone: stringFieldSchema('Time zone', { min: 1 })
+};
+
+const sharedSchema = {
+ officeHoursReason: stringFieldSchema('Office hours reason', {
+ min: 1,
+ max: MAX_TEXT_AREA_LENGTH
+ })
+};
+
+const additionalDetailsSchema = {
+ repeatApplicant: z.boolean().default(false),
+ opportunityOutreachConsent: z.boolean().default(true)
+};
+
+const requiredSchema = {
+ captchaToken: stringFieldSchema('Captcha', { min: 1 })
+};
+
+// Base schema with common fields
+const baseOfficeHoursSchema = z.object({
+ ...baseContactSchema,
+ ...sharedSchema,
+ ...additionalDetailsSchema,
+ ...requiredSchema
+});
+
+// Schema for Advice requests
+const adviceSchema = baseOfficeHoursSchema.extend({
+ officeHoursRequest: z.literal('Advice')
+});
+
+// Schema for Project Feedback requests with required project fields
+const projectFeedbackSchema = baseOfficeHoursSchema.extend({
+ officeHoursRequest: z.literal('Project Feedback'),
+ projectName: stringFieldSchema('Project name', { min: 1, max: 80 }),
+ projectSummary: stringFieldSchema('Project summary', {
+ min: 1,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ fileUpload: z
+ .any()
+ .refine(file => !file || (file?.size ?? 0) <= MAX_WISHLIST_FILE_SIZE, 'Max file size is 4MB.')
+ .refine(
+ file => !file || (file?.type || file?.mimetype) === 'application/pdf',
+ 'File must be a PDF'
+ )
+ .optional()
+ .or(z.literal('')),
+ projectRepo: stringFieldSchema('Project repo', { max: MAX_TEXT_LENGTH }).url({
+ message: 'Invalid URL'
+ }),
+ domain: z.enum([
+ 'Application layer',
+ 'Cryptography',
+ 'DAOs/Governance',
+ 'Decentralized Identity',
+ 'DeFi',
+ 'Economics',
+ 'Ethereum Protocol',
+ 'Government',
+ 'Layer 2',
+ 'NFTs / Digital Art',
+ 'Nodes and Clients',
+ 'Privacy',
+ 'Security',
+ 'Society and Regulatory',
+ 'UX/UI',
+ 'Zero-knowledge Proofs',
+ 'Other'
+ ]),
+ additionalInfo: stringFieldSchema('Additional information', {
+ max: MAX_TEXT_AREA_LENGTH
+ }).optional()
+});
+
+// Create discriminated union
+export const OfficeHoursSchema = z.discriminatedUnion(
+ 'officeHoursRequest',
+ [adviceSchema, projectFeedbackSchema],
+ {
+ errorMap: () => ({
+ message: 'Office hours request type is required'
+ })
+ }
+);
+
+export type OfficeHoursData = z.infer;
diff --git a/src/components/forms/schemas/RFP.ts b/src/components/forms/schemas/RFP.ts
new file mode 100644
index 00000000..a41d7bbc
--- /dev/null
+++ b/src/components/forms/schemas/RFP.ts
@@ -0,0 +1,50 @@
+import {
+ contactInformationSchema,
+ projectOverviewSchema,
+ additionalDetailsSchema,
+ requiredSchema
+} from './BaseGrant';
+import { stringFieldSchema } from './utils';
+import { z } from 'zod';
+import { MAX_TEXT_LENGTH, MAX_WISHLIST_FILE_SIZE } from '../../../constants';
+
+export const RFPSchema = z.object({
+ selectedRFPId: stringFieldSchema('RFP item', { min: 1 }),
+ ...contactInformationSchema,
+ ...projectOverviewSchema,
+ ...additionalDetailsSchema,
+ // Override referral to be optional for RFP forms
+ referral: stringFieldSchema('Referral', { max: MAX_TEXT_LENGTH }).optional(),
+ fileUpload: z
+ .any()
+ .refine(
+ file => !!file,
+ 'For RFP: Attach a PDF proposal that fulfills the requirements of the Request for Proposals.'
+ )
+ .refine(file => (file?.size ?? 0) <= MAX_WISHLIST_FILE_SIZE, 'Max file size is 4MB.')
+ .refine(file => (file?.type || file?.mimetype) === 'application/pdf', 'File must be a PDF'),
+ ...requiredSchema
+});
+
+export type RFPData = z.infer;
+
+export interface RFPItem {
+ Id: string;
+ Name: string;
+ Description__c: string;
+ Category__c?: string;
+ Priority__c?: string;
+ Expected_Deliverables__c?: string;
+ Skills_Required__c?: string;
+ Estimated_Effort__c?: string;
+ Requirements__c?: string;
+ Tags__c?: string;
+ Ecosystem_Need__c?: string;
+ RFP_HardRequirements_Long__c?: string;
+ RFP_SoftRequirements__c?: string;
+ Resources__c?: string;
+ RFP_Open_Date__c?: string;
+ RFP_Close_Date__c?: string;
+ RFP_Project_Duration__c?: string;
+ Custom_URL_Slug__c?: string;
+}
diff --git a/src/components/forms/schemas/Wishlist.ts b/src/components/forms/schemas/Wishlist.ts
new file mode 100644
index 00000000..a982f089
--- /dev/null
+++ b/src/components/forms/schemas/Wishlist.ts
@@ -0,0 +1,56 @@
+import {
+ contactInformationSchema,
+ projectOverviewSchema,
+ projectDetailsSchema,
+ additionalDetailsSchema,
+ requiredSchema
+} from './BaseGrant';
+import { z } from 'zod';
+import {
+ MAX_WISHLIST_FILE_SIZE,
+ MAX_TEXT_LENGTH,
+ MIN_TEXT_AREA_LENGTH,
+ MAX_TEXT_AREA_LENGTH
+} from '../../../constants';
+import { stringFieldSchema } from './utils';
+
+export const WishlistSchema = z.object({
+ selectedWishlistId: stringFieldSchema('Wishlist item', { min: 1 }),
+ ...contactInformationSchema,
+ ...projectOverviewSchema,
+ ...projectDetailsSchema,
+ ...additionalDetailsSchema,
+ applicantProfile: stringFieldSchema('Applicant profile', {
+ min: MIN_TEXT_AREA_LENGTH,
+ max: MAX_TEXT_AREA_LENGTH
+ }),
+ // Override referral to be optional for Wishlist forms
+ referral: stringFieldSchema('Referral', { max: MAX_TEXT_LENGTH }).optional(),
+ ...requiredSchema,
+ // Override file upload field as optional for the Wishlist form
+ fileUpload: z
+ .any()
+ .optional()
+ .refine(file => !file || (file?.size ?? 0) <= MAX_WISHLIST_FILE_SIZE, 'Max file size is 4MB.')
+ .refine(
+ file => !file || (file?.type || file?.mimetype) === 'application/pdf',
+ 'File must be a PDF'
+ )
+});
+
+export type WishlistData = z.infer;
+
+export interface WishlistItem {
+ Id: string;
+ Name: string;
+ Description__c: string;
+ Category__c?: string;
+ Priority__c?: string;
+ Expected_Deliverables__c?: string;
+ Skills_Required__c?: string;
+ Estimated_Effort__c?: string;
+ Tags__c?: string;
+ Out_of_Scope__c?: string;
+ Resources__c?: string;
+ Custom_URL_Slug__c?: string;
+}
diff --git a/src/components/forms/sections/AdditionalDetailsSection.tsx b/src/components/forms/sections/AdditionalDetailsSection.tsx
new file mode 100644
index 00000000..7ece4f7e
--- /dev/null
+++ b/src/components/forms/sections/AdditionalDetailsSection.tsx
@@ -0,0 +1,148 @@
+import { Stack, Radio, RadioGroup } from '@chakra-ui/react';
+import { FC } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+import { TextareaProps } from '@chakra-ui/react';
+
+import { PageSection, PageText } from '../../UI';
+import { TextField, TextAreaField, Field } from '../fields';
+
+interface FieldConfig {
+ label?: string;
+ helpText?: string;
+ isRequired?: boolean;
+ textareaProps?: Pick;
+}
+
+interface AdditionalDetailsSectionProps {
+ fields?: {
+ repeatApplicant?: FieldConfig | false;
+ referral?: FieldConfig | false;
+ additionalInfo?: FieldConfig | false;
+ opportunityOutreachConsent?: FieldConfig | false;
+ };
+}
+
+// Default configurations for each field
+const DEFAULT_FIELDS = {
+ repeatApplicant: {
+ label: 'Have you applied before to any grants at the Ethereum Foundation?',
+ isRequired: true
+ },
+ referral: {
+ label: 'Referral(s)',
+ helpText: 'Do you have an Ethereum Foundation referral for this project?',
+ isRequired: true
+ },
+ additionalInfo: {
+ label: 'Additional questions or comments?',
+ isRequired: false,
+ textareaProps: {
+ rows: 3,
+ resize: 'vertical' as const
+ }
+ },
+ opportunityOutreachConsent: {
+ label: 'Allow contact from Ethereum Foundation about other opportunities?',
+ isRequired: false
+ }
+};
+
+export const AdditionalDetailsSection: FC = ({ fields }) => {
+ const { control } = useFormContext();
+
+ // Merge provided fields config with defaults
+ const repeatApplicantConfig =
+ fields?.repeatApplicant === false
+ ? null
+ : { ...DEFAULT_FIELDS.repeatApplicant, ...fields?.repeatApplicant };
+
+ const referralConfig =
+ fields?.referral === false ? null : { ...DEFAULT_FIELDS.referral, ...fields?.referral };
+
+ const additionalInfoConfig =
+ fields?.additionalInfo === false
+ ? null
+ : { ...DEFAULT_FIELDS.additionalInfo, ...fields?.additionalInfo };
+
+ const opportunityOutreachConsentConfig =
+ fields?.opportunityOutreachConsent === false
+ ? null
+ : { ...DEFAULT_FIELDS.opportunityOutreachConsent, ...fields?.opportunityOutreachConsent };
+
+ return (
+
+ Additional Details
+
+ {repeatApplicantConfig && (
+ (
+
+ onChange(val === 'true')} value={value?.toString()}>
+
+
+ Yes
+
+
+ No
+
+
+
+
+ )}
+ />
+ )}
+
+ {referralConfig && (
+
+ )}
+
+ {additionalInfoConfig && (
+
+ )}
+
+ {opportunityOutreachConsentConfig && (
+ (
+
+ onChange(val === 'true')} value={value?.toString()}>
+
+
+ Yes
+
+
+ No
+
+
+
+
+ )}
+ />
+ )}
+
+ );
+};
diff --git a/src/components/forms/sections/ContactInformationSection.tsx b/src/components/forms/sections/ContactInformationSection.tsx
new file mode 100644
index 00000000..686282d9
--- /dev/null
+++ b/src/components/forms/sections/ContactInformationSection.tsx
@@ -0,0 +1,303 @@
+import { Flex, Stack } from '@chakra-ui/react';
+import { FC } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { PageSection } from '../../UI';
+import { TextField, Field, TextAreaField } from '../fields';
+import { Select } from 'chakra-react-select';
+import { DropdownIndicator } from '../../UI';
+import { chakraStyles } from '../selectStyles';
+
+import { COUNTRY_OPTIONS, TIMEZONE_OPTIONS, PROFILE_TYPE_OPTIONS } from '../constants';
+
+interface FieldConfig {
+ label?: string;
+ helpText?: string;
+ isRequired?: boolean;
+}
+
+interface ContactInformationSectionProps {
+ fields?: {
+ firstName?: FieldConfig | false;
+ lastName?: FieldConfig | false;
+ email?: FieldConfig | false;
+ company?: FieldConfig | false;
+ profileType?: FieldConfig | false;
+ otherProfileType?: FieldConfig | false;
+ alternativeContact?: FieldConfig | false;
+ website?: FieldConfig | false;
+ country?: FieldConfig | false;
+ timezone?: FieldConfig | false;
+ applicantProfile?: FieldConfig | false;
+ };
+}
+
+// Default configurations for each field
+const DEFAULT_FIELDS = {
+ firstName: {
+ label: 'First name',
+ isRequired: true
+ },
+ lastName: {
+ label: 'Last name',
+ isRequired: true
+ },
+ email: {
+ label: 'Email',
+ isRequired: true
+ },
+ company: {
+ label: 'Company',
+ helpText:
+ 'Name of company, team, or organization. If you do not have an organization name, write "N/A"',
+ isRequired: true
+ },
+ profileType: {
+ label: 'Profile Type',
+ isRequired: true
+ },
+ otherProfileType: {
+ label: 'If Other',
+ isRequired: false
+ },
+ alternativeContact: {
+ label: 'Alternative Contact Info',
+ isRequired: false
+ },
+ website: {
+ label: 'Website',
+ isRequired: false
+ },
+ country: {
+ label: 'Country',
+ isRequired: true
+ },
+ timezone: {
+ label: 'Time Zone',
+ isRequired: true
+ },
+ applicantProfile: {
+ label: 'Applicant Profile',
+ helpText:
+ 'Briefly provide a biography of yourself and your team including relevant experience and expertise.',
+ isRequired: true
+ }
+};
+
+export const ContactInformationSection: FC = ({ fields }) => {
+ const { control, watch } = useFormContext();
+
+ const watchProfileType = watch('profileType');
+ const isOtherProfileType = watchProfileType === 'Other';
+
+ // Merge provided fields config with defaults
+ const firstNameConfig =
+ fields?.firstName === false ? null : { ...DEFAULT_FIELDS.firstName, ...fields?.firstName };
+
+ const lastNameConfig =
+ fields?.lastName === false ? null : { ...DEFAULT_FIELDS.lastName, ...fields?.lastName };
+
+ const emailConfig =
+ fields?.email === false ? null : { ...DEFAULT_FIELDS.email, ...fields?.email };
+
+ const companyConfig =
+ fields?.company === false ? null : { ...DEFAULT_FIELDS.company, ...fields?.company };
+
+ const profileTypeConfig =
+ fields?.profileType === false
+ ? null
+ : { ...DEFAULT_FIELDS.profileType, ...fields?.profileType };
+
+ const otherProfileTypeConfig =
+ fields?.otherProfileType === false
+ ? null
+ : { ...DEFAULT_FIELDS.otherProfileType, ...fields?.otherProfileType };
+
+ const alternativeContactConfig =
+ fields?.alternativeContact === false
+ ? null
+ : { ...DEFAULT_FIELDS.alternativeContact, ...fields?.alternativeContact };
+
+ const websiteConfig =
+ fields?.website === false ? null : { ...DEFAULT_FIELDS.website, ...fields?.website };
+
+ const countryConfig =
+ fields?.country === false ? null : { ...DEFAULT_FIELDS.country, ...fields?.country };
+
+ const timezoneConfig =
+ fields?.timezone === false ? null : { ...DEFAULT_FIELDS.timezone, ...fields?.timezone };
+
+ const showNameFields = firstNameConfig || lastNameConfig;
+ const showLocationFields = countryConfig || timezoneConfig;
+
+ const applicantProfileConfig =
+ fields?.applicantProfile === false
+ ? null
+ : { ...DEFAULT_FIELDS.applicantProfile, ...fields?.applicantProfile };
+
+ return (
+
+ Contact Information
+
+ {showNameFields && (
+
+ {firstNameConfig && (
+
+ )}
+ {lastNameConfig && (
+
+ )}
+
+ )}
+
+ {emailConfig && (
+
+ )}
+
+ {companyConfig && (
+
+ )}
+
+ {profileTypeConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof PROFILE_TYPE_OPTIONS)[number])?.value)
+ }
+ options={PROFILE_TYPE_OPTIONS}
+ placeholder='Select profile type'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ {isOtherProfileType && otherProfileTypeConfig && (
+
+ )}
+
+ {alternativeContactConfig && (
+
+ )}
+
+ {websiteConfig && (
+
+ )}
+
+ {showLocationFields && (
+
+ {countryConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof COUNTRY_OPTIONS)[number])?.value)
+ }
+ options={COUNTRY_OPTIONS}
+ placeholder='Select country'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ {timezoneConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof TIMEZONE_OPTIONS)[number])?.value)
+ }
+ options={TIMEZONE_OPTIONS}
+ placeholder='Select time zone'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ )}
+
+ {applicantProfileConfig && (
+
+ )}
+
+ );
+};
diff --git a/src/components/forms/sections/FormBlocks.tsx b/src/components/forms/sections/FormBlocks.tsx
new file mode 100644
index 00000000..2c832d5a
--- /dev/null
+++ b/src/components/forms/sections/FormBlocks.tsx
@@ -0,0 +1,157 @@
+import { Box, Center, Stack, Tag, Wrap, WrapItem } from '@chakra-ui/react';
+import { FC, ReactNode } from 'react';
+
+import { PageText } from '../../UI';
+import { SubmitButton } from '../../SubmitButton';
+import { Captcha } from '../fields';
+import parseStringForUrls from '../../../utils/parseStringForUrls';
+
+interface SelectedItemDisplayProps {
+ selectedItem: {
+ Id: string;
+ Name: string;
+ Description__c: string;
+ Tags__c?: string;
+ Out_of_Scope__c?: string;
+ Resources__c?: string;
+ };
+ displayText: string;
+ extraDetails?: boolean;
+}
+
+/**
+ * Displays the selected item (RFP or Wishlist) in a highlighted box
+ */
+export const SelectedItemDisplay: FC = ({
+ selectedItem,
+ displayText,
+ extraDetails = false
+}) => (
+
+
+
+ {displayText}:
+
+
+ {selectedItem.Name}
+
+
+ {selectedItem.Description__c}
+
+ {extraDetails && (
+ <>
+ {selectedItem.Tags__c && (
+
+
+ Tags
+
+
+ {selectedItem.Tags__c?.split(';')
+ .map(tag => tag.trim())
+ .filter(Boolean)
+ .map(tag => (
+
+
+ {tag}
+
+
+ ))}
+
+
+ )}
+
+ {selectedItem.Out_of_Scope__c && (
+
+
+ Out of Scope
+
+
+ {selectedItem.Out_of_Scope__c}
+
+
+ )}
+
+ {selectedItem.Resources__c && (
+
+
+ Resources
+
+
+ {parseStringForUrls(selectedItem.Resources__c ?? '')}
+
+
+ )}
+ >
+ )}
+
+
+);
+
+interface FormActionsProps {
+ submitText?: string;
+ isSubmitting?: boolean;
+ showCaptcha?: boolean;
+ submitButtonProps?: {
+ height?: string;
+ width?: string;
+ };
+}
+
+/**
+ * Form actions section with optional captcha and submit button
+ */
+export const FormActions: FC = ({
+ submitText = 'Submit Application',
+ isSubmitting = false,
+ showCaptcha = true,
+ submitButtonProps = { height: '56px', width: '310px' }
+}) => (
+ <>
+ {showCaptcha && (
+
+
+
+ )}
+
+
+
+
+ >
+);
+
+interface FormContainerProps {
+ children: ReactNode;
+ spacing?: number;
+}
+
+/**
+ * Container for form sections with consistent spacing
+ */
+export const FormContainer: FC = ({ children, spacing = 8 }) => (
+ {children}
+);
+
+interface FormSectionProps {
+ children: ReactNode;
+ spacing?: number;
+}
+
+/**
+ * Wrapper for individual form sections
+ */
+export const FormSection: FC = ({ children, spacing = 6 }) => (
+ {children}
+);
diff --git a/src/components/forms/sections/OfficeHoursRequestSection.tsx b/src/components/forms/sections/OfficeHoursRequestSection.tsx
new file mode 100644
index 00000000..e9ff8e75
--- /dev/null
+++ b/src/components/forms/sections/OfficeHoursRequestSection.tsx
@@ -0,0 +1,146 @@
+import { Box, Fade, Radio, RadioGroup, Stack } from '@chakra-ui/react';
+import { FC } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+import { Select } from 'chakra-react-select';
+
+import { PageSection, PageText, DropdownIndicator } from '../../UI';
+import { TextField, TextAreaField, Field, UploadFile } from '../fields';
+import { chakraStyles } from '../selectStyles';
+import { DOMAIN_OPTIONS } from '../constants';
+
+const ADVICE = 'Advice';
+const PROJECT_FEEDBACK = 'Project Feedback';
+
+export const OfficeHoursRequestSection: FC = () => {
+ const { control, watch } = useFormContext();
+ const officeHoursRequest = watch('officeHoursRequest');
+ const isRequestingProjectFeedback = officeHoursRequest === PROJECT_FEEDBACK;
+
+ return (
+
+ Office Hours Request
+
+ (
+
+
+
+
+ {ADVICE}
+
+
+
+ {PROJECT_FEEDBACK}
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof DOMAIN_OPTIONS)[number])?.value)
+ }
+ options={DOMAIN_OPTIONS}
+ placeholder='Select'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/forms/sections/ProjectDetailsSection.tsx b/src/components/forms/sections/ProjectDetailsSection.tsx
new file mode 100644
index 00000000..b76af506
--- /dev/null
+++ b/src/components/forms/sections/ProjectDetailsSection.tsx
@@ -0,0 +1,238 @@
+import { Stack } from '@chakra-ui/react';
+import { FC } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+import { Select } from 'chakra-react-select';
+
+import { PageSection } from '../../UI';
+import { TextAreaField, Field } from '../fields';
+import { DropdownIndicator } from '../../UI';
+import { chakraStyles } from '../selectStyles';
+
+import { OPEN_SOURCE_LICENSE_OPTIONS } from '../../../constants';
+
+interface FieldConfig {
+ label?: string;
+ helpText?: string;
+ isRequired?: boolean;
+}
+
+interface ProjectDetailsSectionProps {
+ fields?: {
+ projectStructure?: FieldConfig | false;
+ sustainabilityPlan?: FieldConfig | false;
+ funding?: FieldConfig | false;
+ problemBeingSolved?: FieldConfig | false;
+ measuredImpact?: FieldConfig | false;
+ successMetrics?: FieldConfig | false;
+ ecosystemFit?: FieldConfig | false;
+ communityFeedback?: FieldConfig | false;
+ openSourceLicense?: FieldConfig | false;
+ };
+}
+
+// Default configurations for each field
+const DEFAULT_FIELDS = {
+ projectStructure: {
+ label: 'Project Structure',
+ helpText:
+ 'Provide a detailed breakdown of the project scope of work and timeline including milestones, deliverables, and expected outcomes.',
+ isRequired: true
+ },
+ sustainabilityPlan: {
+ label: 'Sustainability Plan',
+ helpText:
+ 'Share plans towards achieving sustainability (both financial and non-financial) for this project in future.',
+ isRequired: true
+ },
+ funding: {
+ label: 'Funding',
+ helpText: 'Have you received or discussed funding for this project from other parties?',
+ isRequired: true
+ },
+ problemBeingSolved: {
+ label: 'Problem Being Solved',
+ helpText:
+ 'Within this domain, what is the problem identified, who is affected by it, and how does this project provide a solution? Provide concrete examples.',
+ isRequired: true
+ },
+ measuredImpact: {
+ label: 'Measured Impact',
+ helpText:
+ "Depending on the stage of this project, provide metrics for the project's current impact on the ecosystem, e.g. users, page visits, code contributors.",
+ isRequired: true
+ },
+ successMetrics: {
+ label: 'Success Metrics',
+ helpText:
+ 'What quantifiable measurements will be used to gauge the success of this project after completion?',
+ isRequired: true
+ },
+ ecosystemFit: {
+ label: 'Ecosystem Fit',
+ helpText:
+ 'Compare your project to 2-3 similar projects in the ecosystem. How is your work unique or novel?',
+ isRequired: true
+ },
+ communityFeedback: {
+ label: 'Community Feedback',
+ helpText: 'What domain expert or community feedback have you received for this project?',
+ isRequired: true
+ },
+ openSourceLicense: {
+ label: 'Open Source License',
+ helpText: 'Specify which open source license you are using for this project.',
+ isRequired: true
+ }
+};
+
+export const ProjectDetailsSection: FC = ({ fields }) => {
+ const { control } = useFormContext();
+ // Merge provided fields config with defaults
+ const projectStructureConfig =
+ fields?.projectStructure === false
+ ? null
+ : { ...DEFAULT_FIELDS.projectStructure, ...fields?.projectStructure };
+
+ const sustainabilityPlanConfig =
+ fields?.sustainabilityPlan === false
+ ? null
+ : { ...DEFAULT_FIELDS.sustainabilityPlan, ...fields?.sustainabilityPlan };
+
+ const fundingConfig =
+ fields?.funding === false ? null : { ...DEFAULT_FIELDS.funding, ...fields?.funding };
+
+ const problemBeingSolvedConfig =
+ fields?.problemBeingSolved === false
+ ? null
+ : { ...DEFAULT_FIELDS.problemBeingSolved, ...fields?.problemBeingSolved };
+
+ const measuredImpactConfig =
+ fields?.measuredImpact === false
+ ? null
+ : { ...DEFAULT_FIELDS.measuredImpact, ...fields?.measuredImpact };
+
+ const successMetricsConfig =
+ fields?.successMetrics === false
+ ? null
+ : { ...DEFAULT_FIELDS.successMetrics, ...fields?.successMetrics };
+
+ const ecosystemFitConfig =
+ fields?.ecosystemFit === false
+ ? null
+ : { ...DEFAULT_FIELDS.ecosystemFit, ...fields?.ecosystemFit };
+
+ const communityFeedbackConfig =
+ fields?.communityFeedback === false
+ ? null
+ : { ...DEFAULT_FIELDS.communityFeedback, ...fields?.communityFeedback };
+
+ const openSourceLicenseConfig =
+ fields?.openSourceLicense === false
+ ? null
+ : { ...DEFAULT_FIELDS.openSourceLicense, ...fields?.openSourceLicense };
+
+ return (
+
+ Project Details
+
+ {projectStructureConfig && (
+
+ )}
+
+ {sustainabilityPlanConfig && (
+
+ )}
+
+ {fundingConfig && (
+
+ )}
+
+ {problemBeingSolvedConfig && (
+
+ )}
+
+ {measuredImpactConfig && (
+
+ )}
+
+ {successMetricsConfig && (
+
+ )}
+
+ {ecosystemFitConfig && (
+
+ )}
+
+ {communityFeedbackConfig && (
+
+ )}
+
+ {openSourceLicenseConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof OPEN_SOURCE_LICENSE_OPTIONS)[number])?.value)
+ }
+ options={OPEN_SOURCE_LICENSE_OPTIONS}
+ placeholder='Select open source license'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ );
+};
diff --git a/src/components/forms/sections/ProjectOverviewSection.tsx b/src/components/forms/sections/ProjectOverviewSection.tsx
new file mode 100644
index 00000000..006305bc
--- /dev/null
+++ b/src/components/forms/sections/ProjectOverviewSection.tsx
@@ -0,0 +1,265 @@
+import { Flex, Stack } from '@chakra-ui/react';
+import { FC } from 'react';
+import { Controller, useFormContext } from 'react-hook-form';
+
+import { PageSection } from '../../UI';
+import { TextField, TextAreaField, Field, UploadFile } from '../fields';
+import { Select } from 'chakra-react-select';
+import { DropdownIndicator } from '../../UI';
+import { chakraStyles } from '../selectStyles';
+
+import { FIAT_CURRENCY_OPTIONS, DOMAIN_OPTIONS, OUTPUT_OPTIONS } from '../constants';
+
+interface FieldConfig {
+ label?: string;
+ helpText?: string;
+ isRequired?: boolean;
+}
+
+interface FileUploadConfig extends FieldConfig {
+ dropzoneProps?: {
+ accept?: string[];
+ maxFiles?: number;
+ maxSize?: number;
+ };
+}
+
+interface ProjectOverviewSectionProps {
+ fields?: {
+ projectName?: FieldConfig | false;
+ projectSummary?: FieldConfig | false;
+ fileUpload?: FileUploadConfig | false;
+ projectRepo?: FieldConfig | false;
+ domain?: FieldConfig | false;
+ output?: FieldConfig | false;
+ budgetRequest?: FieldConfig | false;
+ currency?: FieldConfig | false;
+ };
+}
+
+// Default configurations for each field
+const DEFAULT_FIELDS = {
+ projectName: {
+ label: 'Project Name',
+ helpText: 'Provide a concise title for your project.',
+ isRequired: true
+ },
+ projectSummary: {
+ label: 'Project Summary',
+ helpText:
+ 'Describe your project in a few sentences, including what is being built and why it matters. Provide links to any existing public or published work.',
+ isRequired: true
+ },
+ fileUpload: {
+ label: 'PDF Proposal',
+ helpText: 'Attach a PDF proposal that fulfills the requirements of the Request for Proposals.',
+ isRequired: true,
+ dropzoneProps: {
+ accept: ['application/pdf'],
+ maxFiles: 1,
+ maxSize: 4194304 // 4MB
+ }
+ },
+ projectRepo: {
+ label: 'Project Repo Link',
+ helpText: 'Include a link to a repository for this project: Github, Gitlab, HackMD',
+ isRequired: false
+ },
+ domain: {
+ label: 'Domain',
+ helpText: 'Select a domain for this project.',
+ isRequired: true
+ },
+ output: {
+ label: 'Output',
+ helpText: 'Select an expected outcome of this project.',
+ isRequired: true
+ },
+ budgetRequest: {
+ label: 'Budget Request',
+ isRequired: true
+ },
+ currency: {
+ label: 'Currency',
+ isRequired: true
+ }
+};
+
+export const ProjectOverviewSection: FC = ({ fields }) => {
+ const { control } = useFormContext();
+
+ // Merge provided fields config with defaults
+ const projectNameConfig =
+ fields?.projectName === false
+ ? null
+ : { ...DEFAULT_FIELDS.projectName, ...fields?.projectName };
+
+ const projectSummaryConfig =
+ fields?.projectSummary === false
+ ? null
+ : { ...DEFAULT_FIELDS.projectSummary, ...fields?.projectSummary };
+
+ // Handle deprecated includeFileUpload prop
+ const fileUploadConfig =
+ fields?.fileUpload === false ? null : { ...DEFAULT_FIELDS.fileUpload, ...fields?.fileUpload };
+
+ const projectRepoConfig =
+ fields?.projectRepo === false
+ ? null
+ : { ...DEFAULT_FIELDS.projectRepo, ...fields?.projectRepo };
+
+ const domainConfig =
+ fields?.domain === false ? null : { ...DEFAULT_FIELDS.domain, ...fields?.domain };
+
+ const outputConfig =
+ fields?.output === false ? null : { ...DEFAULT_FIELDS.output, ...fields?.output };
+
+ const budgetRequestConfig =
+ fields?.budgetRequest === false
+ ? null
+ : { ...DEFAULT_FIELDS.budgetRequest, ...fields?.budgetRequest };
+
+ const currencyConfig =
+ fields?.currency === false ? null : { ...DEFAULT_FIELDS.currency, ...fields?.currency };
+
+ const showBudgetFields = budgetRequestConfig || currencyConfig;
+
+ return (
+
+ Budget
+
+ {showBudgetFields && (
+
+ {budgetRequestConfig && (
+
+ )}
+
+ {currencyConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof FIAT_CURRENCY_OPTIONS)[number])?.value)
+ }
+ options={FIAT_CURRENCY_OPTIONS}
+ placeholder='Select currency'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ )}
+
+ Project Overview
+
+ {projectNameConfig && (
+
+ )}
+
+ {projectSummaryConfig && (
+
+ )}
+
+ {fileUploadConfig && (
+
+ )}
+
+ {projectRepoConfig && (
+
+ )}
+
+ {domainConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof DOMAIN_OPTIONS)[number])?.value)
+ }
+ options={DOMAIN_OPTIONS}
+ placeholder='Select domain'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ {outputConfig && (
+ (
+
+ option.value === value) || null}
+ onChange={selectedOption =>
+ onChange((selectedOption as (typeof OUTPUT_OPTIONS)[number])?.value)
+ }
+ options={OUTPUT_OPTIONS}
+ placeholder='Select output type'
+ components={{ DropdownIndicator }}
+ chakraStyles={chakraStyles}
+ />
+
+ )}
+ />
+ )}
+
+ );
+};
diff --git a/src/components/forms/sections/index.ts b/src/components/forms/sections/index.ts
new file mode 100644
index 00000000..ca801dae
--- /dev/null
+++ b/src/components/forms/sections/index.ts
@@ -0,0 +1,6 @@
+export { ContactInformationSection } from './ContactInformationSection';
+export { ProjectOverviewSection } from './ProjectOverviewSection';
+export { ProjectDetailsSection } from './ProjectDetailsSection';
+export { AdditionalDetailsSection } from './AdditionalDetailsSection';
+export { OfficeHoursRequestSection } from './OfficeHoursRequestSection';
+export { SelectedItemDisplay, FormActions, FormContainer, FormSection } from './FormBlocks';
diff --git a/src/components/layout/ApplicantsLayout.tsx b/src/components/layout/ApplicantsLayout.tsx
index 8cf18211..bfcc3539 100644
--- a/src/components/layout/ApplicantsLayout.tsx
+++ b/src/components/layout/ApplicantsLayout.tsx
@@ -1,6 +1,6 @@
import { Stack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
-import { ReactNode, useState } from 'react';
+import { ReactNode, useState, useEffect } from 'react';
import { Description, NavigationTabs } from '../UI';
@@ -11,16 +11,49 @@ import {
APPLICANTS_TABS_MAP,
APPLICANTS_URL,
GRANTEE_FINANCE_URL,
- OFFICE_HOURS_URL
+ OFFICE_HOURS_URL,
+ WISHLIST_URL,
+ RFP_URL
} from '../../constants';
type Props = {
children: ReactNode;
};
+// Helper function to get tab index for both static and dynamic routes
+const getTabIndexFromPath = (pathname: string): number => {
+ if (APPLICANTS_TABS_MAP[pathname] !== undefined) {
+ return APPLICANTS_TABS_MAP[pathname];
+ }
+
+ if (pathname.startsWith('/applicants/office-hours')) {
+ return 1;
+ }
+
+ if (pathname.startsWith('/applicants/wishlist')) {
+ return 2;
+ }
+
+ if (pathname.startsWith('/applicants/rfp')) {
+ return 3;
+ }
+
+ if (pathname.startsWith('/applicants')) {
+ return 0;
+ }
+
+ return 0;
+};
+
export const ApplicantsLayout = ({ children }: Props) => {
const router = useRouter();
- const [tabIndex, setTabIndex] = useState(APPLICANTS_TABS_MAP[router.pathname]);
+ const pathname = router.pathname;
+
+ const [tabIndex, setTabIndex] = useState(getTabIndexFromPath(pathname));
+
+ useEffect(() => {
+ setTabIndex(getTabIndexFromPath(pathname));
+ }, [pathname]);
const handleChange = (index: number) => {
setTabIndex(index);
@@ -46,12 +79,32 @@ export const ApplicantsLayout = ({ children }: Props) => {
);
break;
+ case 2:
+ router.push(
+ {
+ pathname: WISHLIST_URL
+ },
+ undefined,
+ { scroll: false }
+ );
+ break;
+
+ case 3:
+ router.push(
+ {
+ pathname: RFP_URL
+ },
+ undefined,
+ { scroll: false }
+ );
+ break;
+
default:
break;
}
};
- const isGranteeFinance = router.pathname === GRANTEE_FINANCE_URL;
+ const isGranteeFinance = pathname === GRANTEE_FINANCE_URL;
return (
<>
diff --git a/src/constants.ts b/src/constants.ts
index 24fc8ce7..99d15c43 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -12,12 +12,20 @@ export const HOME_URL = '/';
export const ABOUT_URL = '/about';
export const WHO_WE_SUPPORT_URL = '/about/who-we-support';
export const HOW_WE_SUPPORT_URL = '/about/how-we-support';
+export const SIDEBAR_ABOUT_LINKS: SidebarLink[] = [
+ { text: 'Overview', href: `${ABOUT_URL}/#overview` },
+ { text: 'Grant Management', href: `${ABOUT_URL}/#grant-management` },
+ { text: 'Funding Coordination', href: `${ABOUT_URL}/#funding-coordination` },
+ { text: 'Launchpad', href: `${ABOUT_URL}/#launchpad` }
+];
// applicants
export const APPLICANTS_URL = '/applicants';
export const SIDEBAR_APPLICANTS_LINKS: SidebarLink[] = [
{ text: 'Mission and Scope', href: `${APPLICANTS_URL}/#mission-and-scope` },
- { text: 'How we support', href: `${APPLICANTS_URL}/#how-we-support` }
+ { text: 'Process', href: `${APPLICANTS_URL}/#process` },
+ { text: 'Selection criteria', href: `${APPLICANTS_URL}/#selection-criteria` },
+ { text: 'FAQ', href: `${APPLICANTS_URL}/#faq` }
];
export const OFFICE_HOURS_URL = '/applicants/office-hours';
@@ -32,6 +40,20 @@ export const SIDEBAR_OFFICE_HOURS_LINKS: SidebarLink[] = [
{ text: 'Apply', href: `${OFFICE_HOURS_URL}/#apply` }
];
+export const WISHLIST_URL = '/applicants/wishlist';
+export const SIDEBAR_WISHLIST_LINKS: SidebarLink[] = [
+ { text: 'Summary', href: `${WISHLIST_URL}/#description` },
+ // { text: 'What are wishlist items', href: `${WISHLIST_URL}/#what-are-wishlist-items` },
+ { text: 'Apply', href: `${WISHLIST_URL}/#apply` }
+];
+
+export const RFP_URL = '/applicants/rfp';
+export const SIDEBAR_RFP_LINKS: SidebarLink[] = [
+ { text: 'Summary', href: `${RFP_URL}/#description` },
+ // { text: 'What are RFPs', href: `${RFP_URL}/#what-are-rfps` },
+ { text: 'Apply', href: `${RFP_URL}/#apply` }
+];
+
export const PROJECT_GRANTS_URL = '/applicants/project-grants';
export const SIDEBAR_PROJECT_GRANTS_LINKS: SidebarLink[] = [
{ text: 'Summary', href: `${PROJECT_GRANTS_URL}/#description` },
@@ -271,6 +293,8 @@ export const ZK_GRANTS_LINKS: SidebarLink[] = [
// apply forms
export const PROJECT_GRANTS_APPLY_URL = '/applicants/project-grants/apply';
export const OFFICE_HOURS_APPLY_URL = '/applicants/office-hours/apply';
+export const WISHLIST_APPLY_URL = '/applicants/wishlist/apply';
+export const RFP_APPLY_URL = '/applicants/rfp/apply';
export const SMALL_GRANTS_APPLY_URL = '/applicants/small-grants/apply';
export const ACADEMIC_GRANTS_APPLY_URL = '/academic-grants/apply';
export const DEVCON_GRANTS_APPLY_URL = '/devcon-grants/apply';
@@ -289,7 +313,10 @@ export const GRANTEE_FINANCE_URL = '/applicants/grantee-finance';
// thank you pages
export const PROJECT_GRANTS_THANK_YOU_PAGE_URL = '/applicants/project-grants/thank-you';
export const OFFICE_HOURS_THANK_YOU_PAGE_URL = '/applicants/office-hours/thank-you';
+export const WISHLIST_THANK_YOU_PAGE_URL = '/applicants/wishlist/thank-you';
+export const RFP_THANK_YOU_PAGE_URL = '/applicants/rfp/thank-you';
export const SMALL_GRANTS_THANK_YOU_PAGE_URL = '/applicants/small-grants/thank-you';
+export const DIRECT_GRANT_THANK_YOU_PAGE_URL = '/form-direct/apply/thank-you';
export const GRANTEE_FINANCE_THANK_YOU_PAGE_URL = '/applicants/grantee-finance/thank-you';
export const ACADEMIC_GRANTS_THANK_YOU_PAGE_URL = '/academic-grants/thank-you';
export const DEVCON_GRANTS_THANK_YOU_PAGE_URL = '/devcon-grants/thank-you';
@@ -333,13 +360,28 @@ export const LAYER_2_GRANTS_EMAIL_ADDRESS = 'layer2grants@ethereum.org';
export const ACCOUNT_ABSTRACTION_GRANTS_EMAIL_ADDRESS = 'account-abstraction@ethereum.org';
export const GRANTS_EMAIL_ADDRESS = 'grant-rounds@ethereum.org';
export const PECTRA_PGR_EMAIL_ADDRESS = 'grant-rounds@ethereum.org';
+export const FOUNDER_SUCCESS_URL = 'https://ethereum.org/founders/';
+export const ENTERPRISE_ACCELERATION_URL = 'https://institutions.ethereum.org/';
+export const ETHEREUM_EVERYWHERE_URL = 'https://docs.google.com/forms/d/e/1FAIpQLSeA-W8iy2PJxrY3TD4lMYXyky_wLd4QB_7NRwqSxCd0e19MUg/viewform';
+export const FUNDING_COORDINATION_EMAIL = 'vinay.vasanji@ethereum.org';
+export const ARGOT_COLLECTIVE_URL = 'https://www.argot.org/';
+export const REMIX_LABS_URL = 'https://remix-project.org/';
+export const POWDR_LABS_URL = 'https://www.powdr.org/';
+export const LAUNCHPAD_EMAIL = 'martin.hansen@ethereum.org';
+
// applicants tabs
-export const APPLICANTS_TABS = ['Overview', 'Office Hours'];
+export const APPLICANTS_TABS = ['Overview', 'Office Hours', 'Wishlist', 'RFPs'];
export const APPLICANTS_TABS_MAP: TabsMap = {
[APPLICANTS_URL]: 0,
[OFFICE_HOURS_URL]: 1,
[OFFICE_HOURS_APPLY_URL]: 1,
- [OFFICE_HOURS_THANK_YOU_PAGE_URL]: 1
+ [OFFICE_HOURS_THANK_YOU_PAGE_URL]: 1,
+ [WISHLIST_URL]: 2,
+ [WISHLIST_APPLY_URL]: 2,
+ [WISHLIST_THANK_YOU_PAGE_URL]: 2,
+ [RFP_URL]: 3,
+ [RFP_APPLY_URL]: 3,
+ [RFP_THANK_YOU_PAGE_URL]: 3
};
// about tabs
@@ -368,11 +410,16 @@ export const DOWNLOAD_APPLICATION_URL = '/projectGrantsApplication.docx';
export const MAX_TEXT_LENGTH = 255;
export const MAX_TEXT_AREA_LENGTH = 2000;
export const MIN_TEXT_AREA_LENGTH = 500;
+// Todo: get clarity from Vanessa here
+export const CUSTOM_MIN_TEXT_AREA_LENGTH = 10;
// proposal upload file size limit (4mb)
export const MAX_PROPOSAL_FILE_SIZE = 4194304;
export const MAX_PROPOSAL_FILE_COUNT = 5;
+// wishlist upload file size limit (4mb)
+export const MAX_WISHLIST_FILE_SIZE = 4194304;
+
// toast options
export const TOAST_OPTIONS: UseToastOptions = {
position: 'top-right',
@@ -417,3 +464,18 @@ export const EPF_APPLICATION_PREVIEW_URL = 'https://esp.ethereum.foundation/imag
// Thank you and apply urls
export const GRANTS_URLS = [DEVCON_GRANTS_APPLY_URL, DEVCON_GRANTS_THANK_YOU_PAGE_URL];
+
+export const OPEN_SOURCE_LICENSE_OPTIONS = [
+ { value: 'MIT', label: 'MIT' },
+ { value: 'Apache-2.0', label: 'Apache-2.0' },
+ { value: 'BSD Licenses', label: 'BSD Licenses' },
+ { value: 'ISC License', label: 'ISC License' },
+ { value: 'BSL-1.0', label: 'BSL-1.0' },
+ { value: 'GPL-3.0', label: 'GPL-3.0' },
+ { value: 'GPL-2.0', label: 'GPL-2.0' },
+ { value: 'AGPL-3.0', label: 'AGPL-3.0' },
+ { value: 'Unlicense', label: 'Unlicense' },
+ { value: 'CC0-1.0', label: 'CC0-1.0' },
+ { value: 'Other', label: 'Other' },
+ { value: 'N/A', label: 'N/A' }
+];
diff --git a/src/data/rfpItems.ts b/src/data/rfpItems.ts
new file mode 100644
index 00000000..9bdbe355
--- /dev/null
+++ b/src/data/rfpItems.ts
@@ -0,0 +1,137 @@
+import { RFPItem } from '../components/forms/schemas/RFP';
+
+export const rfpItems: RFPItem[] = [
+ {
+ Id: 'RFP001',
+ Name: 'Ethereum Client Optimization Research',
+ Description__c:
+ 'Research and develop optimization techniques for Ethereum execution clients to improve sync times, reduce resource consumption, and enhance overall network performance.',
+ Category__c: 'Research',
+ Priority__c: 'High',
+ Expected_Deliverables__c:
+ 'Research report, proof-of-concept implementation, performance benchmarks, and optimization recommendations',
+ Skills_Required__c:
+ 'Ethereum protocol expertise, systems programming, performance optimization, benchmarking',
+ Estimated_Effort__c: '6-12 months',
+ Tags__c: 'Execution Clients;Performance;Research',
+ Ecosystem_Need__c:
+ 'Maintain client diversity and operational excellence for the Ethereum network by improving client performance and resiliency.',
+ RFP_HardRequirements_Long__c:
+ 'Proven experience with at least one Ethereum execution client (Geth, Erigon, Nethermind, or Besu).\nDemonstrated track record in systems-level performance optimization.',
+ RFP_SoftRequirements__c:
+ 'Ability to collaborate with client teams and share findings openly.\nComfort presenting results to technical stakeholders.',
+ Resources__c:
+ 'https://github.com/ethereum/execution-specs\nhttps://github.com/ethereum/pm',
+ RFP_Open_Date__c: '2024-08-01',
+ RFP_Close_Date__c: '2024-09-30',
+ RFP_Project_Duration__c: '6-12 months'
+ },
+ {
+ Id: 'RFP002',
+ Name: 'MEV-Boost Relay Decentralization Study',
+ Description__c:
+ 'Comprehensive analysis of current MEV-Boost relay infrastructure and proposal for decentralization mechanisms to reduce single points of failure and censorship risks.',
+ Category__c: 'Research',
+ Priority__c: 'High',
+ Expected_Deliverables__c:
+ 'Technical specification, prototype implementation, security analysis, and deployment roadmap',
+ Skills_Required__c:
+ 'MEV expertise, consensus layer knowledge, cryptographic protocols, distributed systems',
+ Estimated_Effort__c: '8-14 months',
+ Tags__c: 'MEV;Consensus;Research',
+ Ecosystem_Need__c:
+ 'Reduce centralization risks and censorship vectors in current MEV-Boost relay infrastructure.',
+ RFP_HardRequirements_Long__c:
+ 'Extensive knowledge of Ethereum consensus and MEV supply chain mechanics.\nAbility to model and analyze relay operator incentives.',
+ RFP_SoftRequirements__c:
+ 'Established relationships across MEV stakeholders and validator communities.\nStrong communication skills for cross-organizational coordination.',
+ Resources__c:
+ 'https://github.com/flashbots/mev-boost\nhttps://ethresear.ch/c/mev/21',
+ RFP_Open_Date__c: '2024-07-15',
+ RFP_Close_Date__c: '2024-09-15',
+ RFP_Project_Duration__c: '8-14 months'
+ },
+ {
+ Id: 'RFP003',
+ Name: 'Layer 2 Interoperability Framework',
+ Description__c:
+ 'Design and implement a standardized framework for cross-layer communication and asset bridging between different Layer 2 solutions while maintaining security guarantees.',
+ Category__c: 'Protocol Development',
+ Priority__c: 'Medium',
+ Expected_Deliverables__c:
+ 'Protocol specification, reference implementation, security audit, integration guides for L2 projects',
+ Skills_Required__c:
+ 'Layer 2 protocols, bridge security, smart contract development, formal verification',
+ Estimated_Effort__c: '10-18 months',
+ Tags__c: 'Layer 2;Interoperability;Protocol Design',
+ Ecosystem_Need__c:
+ 'Improve composability and safety for cross-rollup applications and liquidity.',
+ RFP_HardRequirements_Long__c:
+ 'Hands-on experience building on multiple rollup stacks.\nSecurity reviews completed for bridge or interoperability systems.',
+ RFP_SoftRequirements__c:
+ 'Strong community engagement record with L2 teams.\nAbility to produce clear documentation for external integrators.',
+ Resources__c:
+ 'https://l2beat.com/\nhttps://research.scroll.io/\nhttps://docs.arbitrum.io/',
+ RFP_Open_Date__c: '2024-06-01',
+ RFP_Close_Date__c: '2024-08-31',
+ RFP_Project_Duration__c: '10-18 months'
+ },
+ {
+ Id: 'RFP004',
+ Name: 'Zero-Knowledge Proof Hardware Acceleration',
+ Description__c:
+ 'Develop specialized hardware acceleration solutions for zero-knowledge proof generation and verification to improve performance and reduce costs for ZK applications.',
+ Category__c: 'Hardware',
+ Priority__c: 'Medium',
+ Expected_Deliverables__c:
+ 'Hardware design specifications, FPGA/ASIC prototypes, performance benchmarks, open-source implementations',
+ Skills_Required__c:
+ 'Hardware design, FPGA/ASIC development, cryptography, zero-knowledge proofs',
+ Estimated_Effort__c: '12-24 months',
+ Tags__c: 'Zero Knowledge;Hardware;Acceleration',
+ Ecosystem_Need__c:
+ 'Enable scalable, affordable ZK proof generation for rollups and privacy-preserving applications.',
+ RFP_HardRequirements_Long__c:
+ 'Expertise shipping FPGA or ASIC designs to production.\nDeep familiarity with contemporary ZK proving systems (PLONK, STARK, Groth16).',
+ RFP_SoftRequirements__c:
+ 'Experience collaborating with protocol and application teams.\nOpen-source ethos and willingness to upstream improvements.',
+ Resources__c:
+ 'https://zkproof.org/\nhttps://github.com/privacy-scaling-explorations\nhttps://github.com/barretenberg/barretenberg',
+ RFP_Open_Date__c: '2024-05-20',
+ RFP_Close_Date__c: '2024-08-01',
+ RFP_Project_Duration__c: '12-24 months'
+ },
+ {
+ Id: 'RFP005',
+ Name: 'Ethereum Archive Node Optimization',
+ Description__c:
+ 'Research and develop solutions to reduce storage requirements and improve query performance for Ethereum archive nodes while maintaining full historical data access.',
+ Category__c: 'Infrastructure',
+ Priority__c: 'High',
+ Expected_Deliverables__c:
+ 'Optimized archive node implementation, compression algorithms, query optimization techniques, deployment guide',
+ Skills_Required__c:
+ 'Database optimization, storage systems, Ethereum protocol, data compression',
+ Estimated_Effort__c: '6-10 months',
+ Tags__c: 'Infrastructure;Archive Nodes;Data',
+ Ecosystem_Need__c:
+ 'Lower barriers for operating archive nodes while maintaining full historical data availability.',
+ RFP_HardRequirements_Long__c:
+ 'Demonstrated work with large-scale distributed storage or database engines.\nProficiency in systems programming languages (Go, Rust, or C++).',
+ RFP_SoftRequirements__c:
+ 'Ability to collaborate with node operator community.\nComfort sharing reproducible benchmarks and best practices.',
+ Resources__c:
+ 'https://github.com/ethereum/go-ethereum\nhttps://github.com/ledgerwatch/erigon\nhttps://ethereum.org/en/developers/docs/nodes-and-clients/',
+ RFP_Open_Date__c: '2024-07-01',
+ RFP_Close_Date__c: '2024-09-10',
+ RFP_Project_Duration__c: '6-10 months'
+ }
+];
+
+export const getActiveRFPItems = (): RFPItem[] => {
+ return rfpItems;
+};
+
+export const getRFPItemById = (id: string): RFPItem | undefined => {
+ return rfpItems.find(item => item.Id === id);
+};
diff --git a/src/data/wishlistItems.ts b/src/data/wishlistItems.ts
new file mode 100644
index 00000000..03ae5217
--- /dev/null
+++ b/src/data/wishlistItems.ts
@@ -0,0 +1,94 @@
+import { WishlistItem } from '../components/forms/schemas/Wishlist';
+
+export const wishlistItems: WishlistItem[] = [
+ {
+ Id: 'WL001',
+ Name: 'Zero-Knowledge Proof Educational Content',
+ Description__c:
+ 'Create comprehensive educational materials about ZK proofs, including tutorials, examples, and interactive demos to help developers understand and implement ZK solutions.',
+ Category__c: 'Education',
+ Priority__c: 'High',
+ Expected_Deliverables__c:
+ 'Interactive tutorials, code examples, documentation, and video content',
+ Skills_Required__c: 'ZK expertise, technical writing, web development',
+ Estimated_Effort__c: '3-6 months',
+ Tags__c: 'Education;Zero Knowledge;Content',
+ Out_of_Scope__c:
+ 'Work focused solely on centralized learning platforms or paywalled curricula is out of scope.',
+ Resources__c:
+ 'https://ethereum.org/en/zero-knowledge/\nhttps://zk-learning.org/resources'
+ },
+ {
+ Id: 'WL002',
+ Name: 'Layer 2 Gas Optimization Tools',
+ Description__c:
+ 'Develop tools to help developers optimize gas usage on various Layer 2 solutions, including cost comparison and optimization recommendations.',
+ Category__c: 'Developer Tools',
+ Priority__c: 'High',
+ Expected_Deliverables__c: 'Gas optimization toolkit, comparison dashboard, integration guides',
+ Skills_Required__c: 'Solidity, Layer 2 protocols, web development',
+ Estimated_Effort__c: '4-8 months',
+ Tags__c: 'Layer 2;Gas Optimizations;Tooling',
+ Out_of_Scope__c:
+ 'Standalone cost dashboards that do not provide actionable optimization guidance are out of scope.',
+ Resources__c:
+ 'https://l2beat.com/\nhttps://docs.optimism.io/builders/optimism-gas-fees'
+ },
+ {
+ Id: 'WL003',
+ Name: 'Ethereum Staking Pool Security Analysis',
+ Description__c:
+ 'Conduct comprehensive security analysis of staking pool implementations and create best practices guide for secure staking pool development.',
+ Category__c: 'Security',
+ Priority__c: 'Medium',
+ Expected_Deliverables__c:
+ 'Security analysis report, best practices documentation, audit checklist',
+ Skills_Required__c: 'Security auditing, Ethereum consensus, smart contracts',
+ Estimated_Effort__c: '2-4 months',
+ Tags__c: 'Security;Staking;Research',
+ Out_of_Scope__c:
+ 'Audits focused only on single validator setups or centralized custodial pools are out of scope.',
+ Resources__c:
+ 'https://ethereum.org/en/staking/\nhttps://ethereum.org/en/developers/docs/nodes-and-clients/'
+ },
+ {
+ Id: 'WL004',
+ Name: 'DeFi Protocol Composability Framework',
+ Description__c:
+ 'Build a framework that helps developers understand and implement secure composability patterns when building on top of existing DeFi protocols.',
+ Category__c: 'DeFi',
+ Priority__c: 'Medium',
+ Expected_Deliverables__c: 'Composability framework, integration patterns, security guidelines',
+ Skills_Required__c: 'DeFi protocols, smart contracts, security analysis',
+ Estimated_Effort__c: '6-12 months',
+ Tags__c: 'DeFi;Composability;Architecture',
+ Out_of_Scope__c:
+ 'Single-protocol integrations or closed-source proprietary tooling is out of scope.',
+ Resources__c:
+ 'https://ethereum.org/en/defi/\nhttps://gov.uniswap.org/\nhttps://community.aave.com/'
+ },
+ {
+ Id: 'WL005',
+ Name: 'Ethereum Node Operation Monitoring',
+ Description__c:
+ 'Create monitoring and alerting tools for Ethereum node operators to track node health, performance, and network participation.',
+ Category__c: 'Infrastructure',
+ Priority__c: 'High',
+ Expected_Deliverables__c: 'Monitoring dashboard, alerting system, performance metrics',
+ Skills_Required__c: 'System administration, Ethereum clients, monitoring tools',
+ Estimated_Effort__c: '3-6 months',
+ Tags__c: 'Infrastructure;Monitoring;Operations',
+ Out_of_Scope__c:
+ 'Hosted SaaS monitoring offerings without an open-source component are out of scope.',
+ Resources__c:
+ 'https://ethnode.org/resources\nhttps://github.com/ethereum/eth2.0-specs\nhttps://grafana.com/solutions/blockchain/'
+ }
+];
+
+export const getActiveWishlistItems = (): WishlistItem[] => {
+ return wishlistItems;
+};
+
+export const getWishlistItemById = (id: string): WishlistItem | undefined => {
+ return wishlistItems.find(item => item.Id === id);
+};
diff --git a/src/lib/sf/index.ts b/src/lib/sf/index.ts
new file mode 100644
index 00000000..333a3e94
--- /dev/null
+++ b/src/lib/sf/index.ts
@@ -0,0 +1,332 @@
+import fs from 'fs';
+import jsforce from 'jsforce';
+import jwt from 'jsonwebtoken';
+import type { File } from 'formidable';
+
+import { GrantInitiative, GrantInitiativeSalesforceRecord, GrantInitiativeType } from '../../types';
+import { truncateString } from '../../utils/truncateString';
+
+const {
+ SF_PROD_LOGIN_URL,
+ SF_PROD_USERNAME,
+ SF_PROD_PASSWORD,
+ SF_PROD_SECURITY_TOKEN,
+ CSAT_JWT_SECRET
+} = process.env;
+
+export const WISHLIST_RECORD_TYPE_ID = '012Vj000008tfPKIAY';
+export const RFP_RECORD_TYPE_ID = '012Vj000008tfPJIAY';
+
+/**
+ * Generate a JWT token for CSAT submission
+ * @param applicationId - The Salesforce Application ID
+ * @returns JWT token valid for 7 days
+ */
+export const generateCSATToken = (applicationId: string): string => {
+ if (!CSAT_JWT_SECRET) {
+ throw new Error('CSAT_JWT_SECRET environment variable is not set');
+ }
+
+ return jwt.sign({ applicationId }, CSAT_JWT_SECRET, { expiresIn: '7d' });
+};
+
+/**
+ * Verify and decode a CSAT JWT token
+ * @param token - The JWT token to verify
+ * @returns Decoded payload with applicationId, or null if invalid
+ */
+export const verifyCSATToken = (token: string): { applicationId: string } | null => {
+ if (!CSAT_JWT_SECRET) {
+ throw new Error('CSAT_JWT_SECRET environment variable is not set');
+ }
+
+ try {
+ const decoded = jwt.verify(token, CSAT_JWT_SECRET) as { applicationId: string };
+ return decoded;
+ } catch (error) {
+ console.error('JWT verification failed:', error);
+ return null;
+ }
+};
+
+const createConnection = (): jsforce.Connection => {
+ return new jsforce.Connection({
+ loginUrl: SF_PROD_LOGIN_URL
+ });
+};
+
+const loginToSalesforce = (conn: jsforce.Connection): Promise => {
+ return new Promise((resolve, reject) => {
+ conn.login(SF_PROD_USERNAME!, `${SF_PROD_PASSWORD}${SF_PROD_SECURITY_TOKEN}`, err => {
+ if (err) {
+ console.error('Salesforce login error:', err);
+ return reject(err);
+ }
+ resolve();
+ });
+ });
+};
+
+const getGrantInitiativeType = (recordTypeId: string): GrantInitiativeType | null => {
+ if (recordTypeId === WISHLIST_RECORD_TYPE_ID) return 'Wishlist';
+ if (recordTypeId === RFP_RECORD_TYPE_ID) return 'RFP';
+ return null;
+};
+
+const getRecordTypeIdForType = (type: GrantInitiativeType): string => {
+ if (type === 'Wishlist') return WISHLIST_RECORD_TYPE_ID;
+ if (type === 'RFP') return RFP_RECORD_TYPE_ID;
+ return '';
+};
+
+const getFieldsForType = (type?: GrantInitiativeType): string => {
+ const baseFields = 'Id,Name,Description__c,RecordTypeId,Tags__c,Resources__c,Ecosystem_Need__c';
+ const wishlistFields = ',Out_of_Scope__c,Custom_URL_Slug__c';
+ const rfpFields =
+ ',RFP_HardRequirements_Long__c,RFP_SoftRequirements__c,RFP_Project_Duration__c,RFP_Close_Date__c,RFP_Open_Date__c,Custom_URL_Slug__c';
+
+ if (type === 'Wishlist') {
+ return baseFields + wishlistFields;
+ }
+
+ if (type === 'RFP') {
+ return baseFields + rfpFields;
+ }
+
+ console.log('Type is,', type);
+
+ return baseFields;
+};
+
+/**
+ * Get all active grant initiative items
+ * @param type - The type of grant initiative (Wishlist, RFP)
+ * @returns Promise with the grant initiative items
+ */
+export function getGrantInitiativeItems(type?: GrantInitiativeType) {
+ return new Promise(async (resolve, reject) => {
+ const conn = createConnection();
+
+ try {
+ await loginToSalesforce(conn);
+
+ // TODO: Change to `Active` before deploying to production
+ const baseCriteria: Partial = { Status__c: 'Active' };
+ const criteria =
+ type != null
+ ? { ...baseCriteria, RecordTypeId: getRecordTypeIdForType(type) }
+ : baseCriteria;
+
+ conn
+ .sobject('Grant_Initiative__c')
+ .find(criteria, getFieldsForType(type), (err, ret) => {
+ if (err) {
+ console.error(err);
+ return reject(err);
+ }
+
+ const grantInitiativeItems = ret.reduce((acc, record) => {
+ const grantInitiativeType = getGrantInitiativeType(record.RecordTypeId);
+ if (!grantInitiativeType) return acc;
+
+ const grantInitiativeItem: GrantInitiative = {
+ Id: record.Id,
+ Name: record.Name,
+ Description__c: record.Description__c,
+ Tags__c: record.Tags__c,
+ Resources__c: record.Resources__c,
+ Ecosystem_Need__c: record.Ecosystem_Need__c
+ };
+
+ if (record.Custom_URL_Slug__c) {
+ grantInitiativeItem.Custom_URL_Slug__c = record.Custom_URL_Slug__c;
+ }
+
+ if (grantInitiativeType === 'Wishlist') {
+ if (record.Out_of_Scope__c) {
+ grantInitiativeItem.Out_of_Scope__c = record.Out_of_Scope__c;
+ }
+ }
+
+ if (grantInitiativeType === 'RFP') {
+ if (record.RFP_Project_Duration__c) {
+ grantInitiativeItem.RFP_Project_Duration__c = record.RFP_Project_Duration__c;
+ }
+ if (record.RFP_HardRequirements_Long__c) {
+ grantInitiativeItem.RFP_HardRequirements_Long__c =
+ record.RFP_HardRequirements_Long__c;
+ }
+ if (record.RFP_SoftRequirements__c) {
+ grantInitiativeItem.RFP_SoftRequirements__c = record.RFP_SoftRequirements__c;
+ }
+ if (record.RFP_Close_Date__c) {
+ grantInitiativeItem.RFP_Close_Date__c = record.RFP_Close_Date__c;
+ }
+ if (record.RFP_Open_Date__c) {
+ grantInitiativeItem.RFP_Open_Date__c = record.RFP_Open_Date__c;
+ }
+ if (record.RFP_Project_Duration__c) {
+ grantInitiativeItem.RFP_Project_Duration__c = record.RFP_Project_Duration__c;
+ }
+ }
+
+ acc.push(grantInitiativeItem);
+ return acc;
+ }, []);
+
+ return resolve(grantInitiativeItems);
+ });
+ } catch (error) {
+ return reject(error);
+ }
+ });
+}
+
+/**
+ * Generic function to create any Salesforce object
+ * @param objectType - The Salesforce object type (e.g., 'Lead', 'Application__c')
+ * @param data - The data to be created
+ * @returns Promise with the created record information
+ */
+export const createSalesforceRecord = async (
+ objectType: string,
+ data: Record
+): Promise<{ id: string; success: boolean }> => {
+ return new Promise(async (resolve, reject) => {
+ const conn = createConnection();
+
+ try {
+ await loginToSalesforce(conn);
+
+ conn.sobject(objectType).create(data, (err, ret) => {
+ if (err || !ret.success) {
+ console.error(`Error creating ${objectType}:`, err);
+ return reject(err || new Error(`${objectType} creation failed`));
+ }
+
+ console.log(`${objectType} with ID: ${ret.id} has been created!`);
+ resolve({ id: ret.id, success: ret.success });
+ });
+ } catch (error) {
+ console.error(`Error in create${objectType}:`, error);
+ reject(error);
+ }
+ });
+};
+
+/**
+ * Generic function to update any Salesforce object
+ * @param objectType - The Salesforce object type (e.g., 'Lead', 'Application__c')
+ * @param id - The ID of the record to update
+ * @param data - The data to be updated
+ * @returns Promise with the update result
+ */
+export const updateSalesforceRecord = async (
+ objectType: string,
+ id: string,
+ data: Record
+): Promise<{ id: string; success: boolean }> => {
+ return new Promise(async (resolve, reject) => {
+ const conn = createConnection();
+
+ try {
+ await loginToSalesforce(conn);
+
+ conn.sobject(objectType).update({ Id: id, ...data }, (err, ret) => {
+ if (err || !ret.success) {
+ console.error(`Error updating ${objectType}:`, err);
+ return reject(err || new Error(`${objectType} update failed`));
+ }
+
+ console.log(`${objectType} with ID: ${ret.id} has been updated!`);
+ resolve({ id: ret.id, success: ret.success });
+ });
+ } catch (error) {
+ console.error(`Error in update${objectType}:`, error);
+ reject(error);
+ }
+ });
+};
+
+/**
+ * Upload a file to Salesforce and link it to a specific object
+ * @param file - The file to upload (from formidable)
+ * @param linkedEntityId - The Salesforce object ID to link the file to
+ * @param titlePrefix - Optional prefix for the file title (default: '[DOCUMENT]')
+ * @param projectName - Optional project name to include in the title
+ * @returns Promise with upload result
+ */
+export const uploadFileToSalesforce = async (
+ file: File,
+ linkedEntityId: string,
+ titlePrefix: string = '[DOCUMENT]',
+ projectName?: string
+): Promise<{ success: boolean; contentDocumentId?: string }> => {
+ return new Promise(async (resolve, reject) => {
+ const conn = createConnection();
+
+ try {
+ await loginToSalesforce(conn);
+
+ // Read file content as base64
+ let fileContent: string;
+ try {
+ fileContent = fs.readFileSync(file.filepath, { encoding: 'base64' });
+ } catch (error) {
+ console.error('Error reading file:', error);
+ return reject(new Error('Failed to read file content'));
+ }
+
+ // Create the file title
+ const baseTitle = projectName
+ ? `${titlePrefix} ${truncateString(projectName, 200)} - ${linkedEntityId}`
+ : `${titlePrefix} ${linkedEntityId}`;
+
+ // Upload file to Salesforce
+ conn.sobject('ContentVersion').create(
+ {
+ Title: baseTitle,
+ PathOnClient: file.originalFilename,
+ VersionData: fileContent
+ },
+ async (err, uploadResult) => {
+ if (err || !uploadResult.success) {
+ console.error('Error uploading file to Salesforce:', err);
+ return reject(err || new Error('File upload failed'));
+ }
+
+ console.log('File uploaded successfully:', uploadResult);
+
+ try {
+ // Get the ContentDocumentId from the uploaded file
+ const contentDocument = await conn
+ .sobject<{
+ Id: string;
+ ContentDocumentId: string;
+ }>('ContentVersion')
+ .retrieve(uploadResult.id);
+
+ // Link the document to the specified entity
+ await conn.sobject('ContentDocumentLink').create({
+ ContentDocumentId: contentDocument.ContentDocumentId,
+ LinkedEntityId: linkedEntityId,
+ ShareType: 'V'
+ });
+
+ console.log(`File successfully linked to entity ${linkedEntityId}`);
+ resolve({
+ success: true,
+ contentDocumentId: contentDocument.ContentDocumentId
+ });
+ } catch (linkError) {
+ console.error('Error linking file to entity:', linkError);
+ reject(new Error('Failed to link file to entity'));
+ }
+ }
+ );
+ } catch (error) {
+ console.error('Error in uploadFileToSalesforce:', error);
+ reject(error);
+ }
+ });
+};
diff --git a/src/middlewares/multipartyParse.ts b/src/middlewares/multipartyParse.ts
index 4a097ac2..3f772293 100644
--- a/src/middlewares/multipartyParse.ts
+++ b/src/middlewares/multipartyParse.ts
@@ -9,28 +9,36 @@ export const multipartyParse =
(req: NextApiRequest, res: NextApiResponse) => {
const form = formidable(options);
- form.parse(req, async (err, fields, files) => {
- if (err) {
- console.error(err);
- res.status(400).json({ status: 'fail' });
- return;
- }
+ return new Promise(resolve => {
+ form.parse(req, async (err, fields, files) => {
+ if (err) {
+ console.error(err);
+ res.status(400).json({ status: 'fail' });
+ return resolve();
+ }
- const parsedFields: Record = {};
- for (const property in fields) {
- const value = fields[property];
+ const parsedFields: Record = {};
+ for (const property in fields) {
+ const value = fields[property];
- try {
- parsedFields[property] = JSON.parse(value as string);
- } catch (err) {
- parsedFields[property] = value;
+ try {
+ parsedFields[property] = JSON.parse(value as string);
+ } catch (err) {
+ parsedFields[property] = value;
+ }
}
- }
- // Extend `req` object with parsed fields and files
- req.fields = parsedFields;
- req.files = files;
+ // Extend `req` object with parsed fields and files
+ req.fields = parsedFields;
+ req.files = files;
- handler(req, res);
+ const maybePromise = handler(req, res);
+ // In case handler returns a promise, wait for it; otherwise resolve on next tick
+ if (maybePromise && typeof (maybePromise as any).then === 'function') {
+ (maybePromise as Promise).finally(() => resolve());
+ } else {
+ resolve();
+ }
+ });
});
};
diff --git a/src/pages/about/index.tsx b/src/pages/about/index.tsx
index c10cacb4..d0160f14 100644
--- a/src/pages/about/index.tsx
+++ b/src/pages/about/index.tsx
@@ -1,8 +1,38 @@
-import { Box, Link, Stack } from '@chakra-ui/react';
+import { Box, Center, Flex, Link, ListItem, Stack } from '@chakra-ui/react';
+import { useInView } from 'react-intersection-observer';
+import Image from 'next/image';
-import { PageSection, PageText, PageMetadata } from '../../components/UI';
+import { ButtonLink } from '../../components';
+import {
+ ApplicantsSidebar,
+ List,
+ PageSection,
+ PageText,
+ PageMetadata,
+} from '../../components/UI';
+
+import {
+ ARGOT_COLLECTIVE_URL,
+ ETHEREUM_GRANTS_URL,
+ FUNDING_COORDINATION_EMAIL,
+ LAUNCHPAD_EMAIL,
+ POWDR_LABS_URL,
+ REMIX_LABS_URL,
+ SIDEBAR_ABOUT_LINKS
+} from '../../constants';
+
+import GrantManagementLogo from '../../../public/images/grant-management-logo.png';
+import FundingCoordinationLogo from '../../../public/images/funding-coordination-logo.png';
+import LaunchpadLogo from '../../../public/images/launchpad-logo.png';
const About = () => {
+ // `threshold` option allows us to control the % of visibility required before triggering the Intersection Observer
+ // https://react-intersection-observer.vercel.app/?path=/story/introduction--page#options
+ const [ref, inView] = useInView({ threshold: 0.3 });
+ const [ref2, inView2] = useInView({ threshold: 0, initialInView: false });
+ const [ref3, inView3] = useInView({ threshold: 0, initialInView: false });
+ const [ref4, inView4] = useInView({ threshold: 0, initialInView: false });
+
return (
<>
{
description="We provide support for open source projects that strengthen Ethereum's foundations, with a particular focus on builder tools, infrastructure, research and public goods."
/>
-
-
-
-
- Our scope
-
-
-
- ESP focuses on strengthening Ethereum's foundations and enabling future builders:
- improving infrastructure, expanding the range of tools available to those building on
- Ethereum, deepening our understanding of cryptographic primitives, and growing the
- builder ecosystem through education and community development. The work we support is
- free, open-source, non-commercial, and built for positive sum outcomes.
-
-
-
- ESP is in the process of refining our priorities and approach, aligning with the
- Ethereum Foundation's updated{' '}
-
- ecosystem development strategy
-
- . Learn more in our{' '}
-
- blog post
- {' '}
- and stay tuned for news on ESP's revised strategy, coming Q4 2025!
-
-
+
+
+
-
-
- Supporting Builders
-
+
+
+
+ Overview
+
+
+ ESP is an ecosystem development cluster within the EF comprising two teams: Grant Management and Funding Coordination.
+
+
+ Together, we focus on strengthening Ethereum's foundations, supporting teams across the ecosystem, and enabling future builders. The work we support is free, open-source, non-commercial, and designed to create positive sum outcomes for the community.
+
+
+ Learn more about each of our teams below!
+
+
+
-
- ESP support is generally directed towards builders rather than directly impacting end
- users. We don't often support dapps or front-end platforms, although this is not
- a hard rule and there are exceptions—for example, where an application serves as a
- research or educational tool, or a reference implementation of a new standard.
-
+
+
+
+ Grant Management
+
+ The Grant Management team focuses on allocating resources to the projects and initiatives that are most critical to Ethereum's resilience and usability.
+
+
+ This involves coordinating grant-making across EF teams to ensure that support is aligned and impactful. In addition, we support grantees throughout their journey by offering guidance, fostering connections across the ecosystem, and drawing insights from outcomes to guide future efforts.
+
+
+
-
- We have supported individuals and teams from all over the world representing different
- backgrounds, disciplines, and levels of experience. This includes companies, DAOs,
- non-profits, institutions, academics, developers, educators, community organizers, and
- more.
-
-
-
+
+
+
+ Funding Coordination
+
+ The Funding Coordination team aims to make it simpler and faster for impactful projects to secure funding. Our work is organized around four pillars: facilitating co-funding for EF grantees, securing co-funding for EF initiatives, improving access to funding opportunities throughout the ecosystem, and expanding the overall pool of available funding.
+
+
+ We execute this vision in various ways, including:
+
+
+
+ Working with Octant, Gitcoin, ENS DAO, and others to co-curate funding domains and align co-funding opportunities for EF grantees
+
+
+ Providing coordination and resource support for EF-led initiatives such as the Proximity Prize, Noir Acceleration, and proactive grants rounds
+
+
+ Improving access to information and processes for builders and projects to secure funding from existing grant sources
+
+
+ Working with TradFi and compliant DeFi entities to explore crypto-native mechanisms for funding public-interest projects on Ethereum
+
+
+
+
+
+
+
+
+
+
+
>
);
diff --git a/src/pages/api/csat.ts b/src/pages/api/csat.ts
new file mode 100644
index 00000000..012878f2
--- /dev/null
+++ b/src/pages/api/csat.ts
@@ -0,0 +1,72 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+import { sanitizeFields } from '../../middlewares/sanitizeFields';
+import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
+import { CSATSchema } from '../../components/forms/schemas/CSAT';
+import { updateSalesforceRecord, verifyCSATToken } from '../../lib/sf';
+
+interface CSATAPIRequest extends NextApiRequest {
+ body: {
+ applicationId: string;
+ csatToken: string;
+ csatRating: number;
+ csatComments?: string;
+ captchaToken: string;
+ };
+}
+
+const handler = async (req: CSATAPIRequest, res: NextApiResponse) => {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ // Validate fields against the schema
+ const result = CSATSchema.safeParse(req.body);
+ if (!result.success) {
+ const formatted = result.error.format();
+ console.error('CSAT validation error:', formatted);
+
+ res.status(400).json({ error: 'Validation failed', details: formatted });
+ return;
+ }
+
+ try {
+ // Verify JWT token
+ const decoded = verifyCSATToken(result.data.csatToken);
+
+ if (!decoded) {
+ console.error('Invalid or expired CSAT token');
+ return res.status(401).json({ error: 'Invalid or expired token' });
+ }
+
+ // Verify that token's applicationId matches the one in the request
+ if (decoded.applicationId !== result.data.applicationId) {
+ console.error('Token applicationId mismatch');
+ return res.status(401).json({ error: 'Token does not match application' });
+ }
+
+ // Update the Application record with CSAT fields
+ const updateData = {
+ Application_CSAT_Rating__c: result.data.csatRating,
+ Application_CSAT_Comments__c: result.data.csatComments || ''
+ };
+
+ await updateSalesforceRecord('Application__c', result.data.applicationId, updateData);
+
+ console.log('CSAT feedback submitted:', {
+ applicationId: result.data.applicationId,
+ rating: result.data.csatRating,
+ hasComments: !!result.data.csatComments
+ });
+
+ res.status(200).json({
+ success: true,
+ message: 'Thank you for your feedback!'
+ });
+ } catch (error) {
+ console.error('Error submitting CSAT feedback:', error);
+ res.status(500).json({ error: 'Failed to submit feedback' });
+ }
+};
+
+export default sanitizeFields(verifyCaptcha(handler));
diff --git a/src/pages/api/direct-grant.ts b/src/pages/api/direct-grant.ts
new file mode 100644
index 00000000..2cafc984
--- /dev/null
+++ b/src/pages/api/direct-grant.ts
@@ -0,0 +1,178 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+import { multipartyParse } from '../../middlewares/multipartyParse';
+import { sanitizeFields } from '../../middlewares/sanitizeFields';
+import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
+import { MAX_WISHLIST_FILE_SIZE } from '../../constants';
+import { DirectGrantSchema } from '../../components/forms/schemas/DirectGrant';
+import { createSalesforceRecord, uploadFileToSalesforce, generateCSATToken } from '../../lib/sf';
+import type { File } from 'formidable';
+
+interface DirectGrantApiRequest extends NextApiRequest {
+ body: {
+ // Contact Information
+ firstName: string;
+ lastName: string;
+ email: string;
+ company: string;
+ profileType: string;
+ otherProfileType?: string;
+ alternativeContact?: string;
+ website?: string;
+ country: string;
+ timezone: string;
+ applicantProfile: string;
+
+ // Project Overview
+ projectName: string;
+ projectSummary: string;
+ projectRepo?: string;
+ domain: string;
+ output: string;
+ budgetRequest: number;
+ currency: string;
+
+ // Project Details
+ projectStructure: string;
+ sustainabilityPlan: string;
+ funding: string;
+ problemBeingSolved: string;
+ measuredImpact: string;
+ successMetrics: string;
+ ecosystemFit: string;
+ communityFeedback: string;
+ openSourceLicense: string;
+
+ // Additional Details
+ repeatApplicant: boolean;
+ referral: string;
+ additionalInfo?: string;
+ opportunityOutreachConsent: boolean;
+
+ // Required for submission
+ captchaToken: string;
+ };
+}
+
+const handler = async (req: DirectGrantApiRequest, res: NextApiResponse) => {
+ const fields = { ...req.fields, ...req.files };
+
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ // validate fields against the schema
+ const result = DirectGrantSchema.safeParse(fields);
+ if (!result.success) {
+ const formatted = result.error.format();
+ console.error(formatted);
+
+ res.status(500).end();
+ return;
+ }
+
+ try {
+ const applicationData = {
+ // Contact Information
+ Application_FirstName__c: result.data.firstName,
+ Application_LastName__c: result.data.lastName,
+ Application_Email__c: result.data.email,
+ Application_Company__c: result.data.company,
+ Application_ProfileType__c: result.data.profileType,
+ Application_Other_ProfileType__c: result.data.otherProfileType,
+ Application_Alternative_Contact__c: result.data.alternativeContact,
+ Application_Website__c: result.data.website,
+ Application_Country__c: result.data.country,
+ Application_Time_Zone__c: result.data.timezone,
+ Application_Profile__c: result.data.applicantProfile,
+
+ // Project Overview
+ Name: result.data.projectName,
+ Application_ProjectDescription__c: result.data.projectSummary,
+ Application_ProjectRepo__c: result.data.projectRepo,
+ Application_Domain__c: result.data.domain,
+ Application_Output__c: result.data.output,
+ Application_RequestedAmount__c: result.data.budgetRequest,
+ CurrencyIsoCode: result.data.currency,
+
+ // Project Details
+ Application_ProjectStructure__c: result.data.projectStructure,
+ Application_SustainabilityPlan__c: result.data.sustainabilityPlan,
+ Application_OtherFunding__c: result.data.funding,
+ Application_Problem__c: result.data.problemBeingSolved,
+ Application_MeasuredImpact__c: result.data.measuredImpact,
+ Application_SuccessMetric__c: result.data.successMetrics,
+ Application_EcosystemFit__c: result.data.ecosystemFit,
+ Application_CommunityFeedback__c: result.data.communityFeedback,
+ Application_Open_Source_License_Picklist__c: result.data.openSourceLicense,
+
+ // Additional Details
+ Application_Repeat_Applicant__c: result.data.repeatApplicant,
+ Application_Referral__c: result.data.referral,
+ Application_AdditionalInformation__c: result.data.additionalInfo,
+ Application_OutreachConsent__c: result.data.opportunityOutreachConsent,
+
+ // Hardwired fields
+ Application_Stage__c: 'New',
+ Application_Source__c: 'Webform',
+ RecordTypeId: '012Vj000008xEVNIA2'
+ };
+
+ const salesforceResult = await createSalesforceRecord('Application__c', applicationData);
+
+ // Handle file upload if present
+ if (result.data.fileUpload) {
+ const uploadProposal = result.data.fileUpload as File;
+
+ // Validate file object has required properties
+ if (!uploadProposal.filepath || !uploadProposal.originalFilename) {
+ console.error('Invalid file object:', uploadProposal);
+ res.status(400).json({ error: 'Invalid file upload' });
+ return;
+ }
+
+ try {
+ await uploadFileToSalesforce(
+ uploadProposal,
+ salesforceResult.id,
+ '[PROPOSAL]',
+ result.data.projectName
+ );
+ console.log('File uploaded successfully');
+ } catch (error) {
+ console.error('Error uploading proposal:', error);
+ res.status(500).json({ error: 'Failed to upload proposal' });
+ return;
+ }
+ }
+
+ console.log('Direct grant application submitted:', {
+ projectName: result.data.projectName,
+ applicant: `${result.data.firstName} ${result.data.lastName}`,
+ email: result.data.email,
+ salesforceId: salesforceResult.id
+ });
+
+ const csatToken = generateCSATToken(salesforceResult.id);
+
+ res.status(200).json({
+ success: true,
+ message: 'Direct grant application submitted successfully',
+ applicationId: salesforceResult.id,
+ csatToken
+ });
+ } catch (error) {
+ console.error('Error submitting direct grant application:', error);
+ res.status(500).json({ error: 'Failed to submit application' });
+ }
+};
+
+export const config = {
+ api: {
+ bodyParser: false
+ }
+};
+
+export default multipartyParse(sanitizeFields(verifyCaptcha(handler)), {
+ maxFileSize: MAX_WISHLIST_FILE_SIZE
+});
diff --git a/src/pages/api/office-hours.ts b/src/pages/api/office-hours.ts
index 57935b69..d857a655 100644
--- a/src/pages/api/office-hours.ts
+++ b/src/pages/api/office-hours.ts
@@ -1,78 +1,165 @@
-import jsforce from 'jsforce';
-import { NextApiResponse } from 'next';
-
-import { verifyCaptcha, sanitizeFields } from '../../middlewares';
-
-import { OfficeHoursNextApiRequest } from '../../types';
-
-async function handler(req: OfficeHoursNextApiRequest, res: NextApiResponse): Promise {
- return new Promise(resolve => {
- const { body } = req;
- const {
- firstName: FirstName,
- lastName: LastName,
- email: Email,
- individualOrTeam: Individual_or_Team__c,
- company: Company,
- officeHoursRequest: Office_Hours_Request__c,
- projectName: Project_Name__c,
- projectDescription: Project_Description__c,
- additionalInfo: Additional_Information__c,
- projectCategory: Category__c,
- otherReasonForMeeting: Reason_for_meeting_if_Other__c,
- howDidYouHearAboutESP: Referral_Source__c,
- country: npsp__CompanyCountry__c,
- timezone: Time_Zone__c
- } = body;
- const { SF_PROD_LOGIN_URL, SF_PROD_USERNAME, SF_PROD_PASSWORD, SF_PROD_SECURITY_TOKEN } =
- process.env;
-
- const conn = new jsforce.Connection({
- // you can change loginUrl to connect to sandbox or prerelease env.
- loginUrl: SF_PROD_LOGIN_URL
- });
+import type { NextApiRequest, NextApiResponse } from 'next';
+import type { File } from 'formidable';
+
+import { multipartyParse } from '../../middlewares/multipartyParse';
+import { sanitizeFields } from '../../middlewares/sanitizeFields';
+import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
+import { MAX_WISHLIST_FILE_SIZE } from '../../constants';
+import { OfficeHoursSchema } from '../../components/forms/schemas/OfficeHours';
+import { createSalesforceRecord, uploadFileToSalesforce, generateCSATToken } from '../../lib/sf';
+
+interface OfficeHoursAPIRequest extends NextApiRequest {
+ body: {
+ // Contact Information
+ firstName: string;
+ lastName: string;
+ email: string;
+ company?: string;
+ profileType: string;
+ otherProfileType?: string;
+ alternativeContact?: string;
+ country: string;
+ timezone: string;
+
+ // Office Hours Request
+ officeHoursRequest: 'Advice' | 'Project Feedback';
+ officeHoursReason: string;
+
+ // Project Feedback specific (conditional)
+ projectName?: string;
+ projectSummary?: string;
+ projectRepo?: string;
+ domain?: string;
+ additionalInfo?: string;
+
+ // Additional Details
+ repeatApplicant: boolean;
+ opportunityOutreachConsent: boolean;
+
+ // Required for submission
+ captchaToken: string;
+ };
+}
+
+const handler = async (req: OfficeHoursAPIRequest, res: NextApiResponse) => {
+ const fields = { ...req.fields, ...req.files };
+
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ // Validate fields against the schema
+ const result = OfficeHoursSchema.safeParse(fields);
+ if (!result.success) {
+ const formatted = result.error.format();
+ console.error('Validation error:', formatted);
+
+ res.status(400).json({ error: 'Validation failed', details: formatted });
+ return;
+ }
+
+ try {
+ // For "Advice" requests, Application Name is "First Name, Last Name"
+ // For "Project Feedback" requests, Application Name is the project name
+ const applicationName =
+ result.data.officeHoursRequest === 'Advice'
+ ? `${result.data.firstName}, ${result.data.lastName}`
+ : result.data.projectName;
+
+ const applicationData = {
+ // Contact Information
+ Application_FirstName__c: result.data.firstName,
+ Application_LastName__c: result.data.lastName,
+ Application_Email__c: result.data.email,
+ Application_Company__c: result.data.company || 'N/A',
+ Application_ProfileType__c: result.data.profileType,
+ Application_Other_ProfileType__c: result.data.otherProfileType,
+ Application_Alternative_Contact__c: result.data.alternativeContact,
+ Application_Country__c: result.data.country,
+ Application_Time_Zone__c: result.data.timezone,
- conn.login(SF_PROD_USERNAME!, `${SF_PROD_PASSWORD}${SF_PROD_SECURITY_TOKEN}`, err => {
- if (err) {
- console.error(err);
- return resolve();
+ // Office Hours Request
+ Application_OfficeHours_RequestType__c: result.data.officeHoursRequest,
+ Application_OfficeHours_Reason__c: result.data.officeHoursReason,
+
+ // Project Feedback specific fields (only present if Project Feedback)
+ Name: applicationName,
+ ...(result.data.officeHoursRequest === 'Project Feedback' && {
+ Application_ProjectDescription__c: result.data.projectSummary,
+ Application_ProjectRepo__c: result.data.projectRepo,
+ Application_Domain__c: result.data.domain,
+ Application_AdditionalInformation__c: result.data.additionalInfo
+ }),
+
+ // Additional Details
+ Application_Repeat_Applicant__c: result.data.repeatApplicant,
+ Application_OutreachConsent__c: result.data.opportunityOutreachConsent,
+
+ // Hardwired fields
+ Application_Stage__c: 'New',
+ Application_Source__c: 'Webform',
+ RecordTypeId: '012Vj000008z3fVIAQ'
+ };
+
+ const salesforceResult = await createSalesforceRecord('Application__c', applicationData);
+
+ // Handle file upload if present (only for Project Feedback)
+ if (
+ result.data.officeHoursRequest === 'Project Feedback' &&
+ 'fileUpload' in result.data &&
+ result.data.fileUpload
+ ) {
+ const uploadFile = result.data.fileUpload as File;
+
+ // Validate file object has required properties
+ if (!uploadFile.filepath || !uploadFile.originalFilename) {
+ console.error('Invalid file object:', uploadFile);
+ res.status(400).json({ error: 'Invalid file upload' });
+ return;
}
- // Single record creation
- conn.sobject('Lead').create(
- {
- FirstName: FirstName.trim(),
- LastName: LastName.trim(),
- Email: Email.trim(),
- Individual_or_Team__c: Individual_or_Team__c.trim(),
- Company: Company.trim(),
- Office_Hours_Request__c: Office_Hours_Request__c.trim(),
- Project_Name__c: Project_Name__c.trim(),
- Project_Description__c: Project_Description__c.trim(),
- Additional_Information__c: Additional_Information__c.trim(),
- Category__c: Category__c.trim(),
- Reason_for_meeting_if_Other__c: Reason_for_meeting_if_Other__c.trim(),
- Referral_Source__c: Referral_Source__c.trim(),
- npsp__CompanyCountry__c: npsp__CompanyCountry__c.trim(),
- Time_Zone__c: Time_Zone__c.trim(),
- LeadSource: 'Webform',
- RecordTypeId: process.env.SF_RECORD_TYPE_OFFICE_HOURS
- },
- (err, ret) => {
- if (err || !ret.success) {
- console.error(err);
- res.status(400).json({ status: 'fail' });
- return resolve();
- } else {
- console.log(`Office Hours Lead with ID: ${ret.id} has been created!`);
-
- res.status(200).json({ status: 'ok' });
- return resolve();
- }
- }
- );
+ try {
+ await uploadFileToSalesforce(
+ uploadFile,
+ salesforceResult.id,
+ '[PROPOSAL]',
+ result.data.projectName
+ );
+ console.log('File uploaded successfully');
+ } catch (error) {
+ console.error('Error uploading file:', error);
+ res.status(500).json({ error: 'Failed to upload file' });
+ return;
+ }
+ }
+
+ console.log('Office Hours application submitted:', {
+ officeHoursRequest: result.data.officeHoursRequest,
+ applicant: `${result.data.firstName} ${result.data.lastName}`,
+ email: result.data.email,
+ salesforceId: salesforceResult.id
});
- });
-}
-export default sanitizeFields(verifyCaptcha(handler));
+ const csatToken = generateCSATToken(salesforceResult.id);
+
+ res.status(200).json({
+ success: true,
+ message: 'Office Hours application submitted successfully',
+ applicationId: salesforceResult.id,
+ csatToken
+ });
+ } catch (error) {
+ console.error('Error submitting Office Hours application:', error);
+ res.status(500).json({ error: 'Failed to submit application' });
+ }
+};
+
+export const config = {
+ api: {
+ bodyParser: false
+ }
+};
+
+export default multipartyParse(sanitizeFields(verifyCaptcha(handler)), {
+ maxFileSize: MAX_WISHLIST_FILE_SIZE
+});
diff --git a/src/pages/api/rfp.ts b/src/pages/api/rfp.ts
new file mode 100644
index 00000000..548876bf
--- /dev/null
+++ b/src/pages/api/rfp.ts
@@ -0,0 +1,159 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+import { multipartyParse } from '../../middlewares/multipartyParse';
+import { sanitizeFields } from '../../middlewares/sanitizeFields';
+import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
+import { MAX_WISHLIST_FILE_SIZE } from '../../constants';
+import { RFPSchema } from '../../components/forms/schemas/RFP';
+import { createSalesforceRecord, uploadFileToSalesforce, generateCSATToken } from '../../lib/sf';
+import type { File } from 'formidable';
+
+interface RFPApIRequest extends NextApiRequest {
+ body: {
+ // RFP Selection
+ selectedRFPId: string;
+
+ // Contact Information
+ firstName: string;
+ lastName: string;
+ email: string;
+ company: string;
+ profileType: string;
+ otherProfileType?: string;
+ alternativeContact?: string;
+ website?: string;
+ country: string;
+ timezone: string;
+
+ // Project Overview
+ projectName: string;
+ projectSummary: string;
+ projectRepo?: string;
+ domain: string;
+ output: string;
+ budgetRequest: number;
+ currency: string;
+
+ // Additional Details
+ repeatApplicant: boolean;
+ referral: string;
+ additionalInfo?: string;
+ opportunityOutreachConsent: boolean;
+
+ // Required for submission
+ captchaToken: string;
+ };
+}
+
+const handler = async (req: RFPApIRequest, res: NextApiResponse) => {
+ const fields = { ...req.fields, ...req.files };
+
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ // validate fields against the schema
+ const result = RFPSchema.safeParse(fields);
+ if (!result.success) {
+ const formatted = result.error.format();
+ console.error(formatted);
+
+ res.status(500).end();
+ return;
+ }
+
+ try {
+ const applicationData = {
+ // Contact Information
+ Application_FirstName__c: result.data.firstName,
+ Application_LastName__c: result.data.lastName,
+ Application_Email__c: result.data.email,
+ Application_Company__c: result.data.company,
+ Application_ProfileType__c: result.data.profileType,
+ Application_Other_ProfileType__c: result.data.otherProfileType,
+ Application_Alternative_Contact__c: result.data.alternativeContact,
+ Application_Website__c: result.data.website,
+ Application_Country__c: result.data.country,
+ Application_Time_Zone__c: result.data.timezone,
+
+ // Project Overview
+ Name: result.data.projectName,
+ Application_ProjectDescription__c: result.data.projectSummary,
+ Application_ProjectRepo__c: result.data.projectRepo,
+ Application_Domain__c: result.data.domain,
+ Application_Output__c: result.data.output,
+ Application_RequestedAmount__c: result.data.budgetRequest,
+ CurrencyIsoCode: result.data.currency,
+
+ // Additional Details
+ Application_Repeat_Applicant__c: result.data.repeatApplicant,
+ Application_Referral__c: result.data.referral,
+ Application_AdditionalInformation__c: result.data.additionalInfo,
+ Application_OutreachConsent__c: result.data.opportunityOutreachConsent,
+
+ // Hardwired fields
+ Grant_Initiative__c: result.data.selectedRFPId,
+ Application_Stage__c: 'New',
+ Application_Source__c: 'Webform',
+ RecordTypeId: '012Vj000008xEVOIA2'
+ };
+
+ const salesforceResult = await createSalesforceRecord('Application__c', applicationData);
+
+ // Handle file upload if present
+ if (result.data.fileUpload) {
+ const uploadProposal = result.data.fileUpload as File;
+
+ // Validate file object has required properties
+ if (!uploadProposal.filepath || !uploadProposal.originalFilename) {
+ console.error('Invalid file object:', uploadProposal);
+ res.status(400).json({ error: 'Invalid file upload' });
+ return;
+ }
+
+ try {
+ await uploadFileToSalesforce(
+ uploadProposal,
+ salesforceResult.id,
+ '[PROPOSAL]',
+ result.data.projectName
+ );
+ console.log('File uploaded successfully');
+ } catch (error) {
+ console.error('Error uploading proposal:', error);
+ res.status(500).json({ error: 'Failed to upload proposal' });
+ return;
+ }
+ }
+
+ console.log('RFP application submitted:', {
+ selectedRFPId: result.data.selectedRFPId,
+ projectName: result.data.projectName,
+ applicant: `${result.data.firstName} ${result.data.lastName}`,
+ email: result.data.email,
+ salesforceId: salesforceResult.id
+ });
+
+ const csatToken = generateCSATToken(salesforceResult.id);
+
+ res.status(200).json({
+ success: true,
+ message: 'RFP application submitted successfully',
+ applicationId: salesforceResult.id,
+ csatToken
+ });
+ } catch (error) {
+ console.error('Error submitting RFP application:', error);
+ res.status(500).json({ error: 'Failed to submit application' });
+ }
+};
+
+export const config = {
+ api: {
+ bodyParser: false
+ }
+};
+
+export default multipartyParse(sanitizeFields(verifyCaptcha(handler)), {
+ maxFileSize: MAX_WISHLIST_FILE_SIZE
+});
diff --git a/src/pages/api/wishlist.ts b/src/pages/api/wishlist.ts
new file mode 100644
index 00000000..a3108944
--- /dev/null
+++ b/src/pages/api/wishlist.ts
@@ -0,0 +1,183 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+
+import { multipartyParse } from '../../middlewares/multipartyParse';
+import { sanitizeFields } from '../../middlewares/sanitizeFields';
+import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
+import { MAX_WISHLIST_FILE_SIZE } from '../../constants';
+import { WishlistSchema } from '../../components/forms/schemas/Wishlist';
+import { createSalesforceRecord, uploadFileToSalesforce, generateCSATToken } from '../../lib/sf';
+import type { File } from 'formidable';
+
+interface WishlistApiRequest extends NextApiRequest {
+ body: {
+ // Wishlist Selection
+ selectedWishlistId: string;
+
+ // Contact Information
+ firstName: string;
+ lastName: string;
+ email: string;
+ company: string;
+ profileType: string;
+ otherProfileType?: string;
+ alternativeContact?: string;
+ website?: string;
+ country: string;
+ timezone: string;
+ applicantProfile: string;
+
+ // Project Overview
+ projectName: string;
+ projectSummary: string;
+ projectRepo?: string;
+ domain: string;
+ output: string;
+ budgetRequest: number;
+ currency: string;
+
+ // Project Details
+ projectStructure: string;
+ sustainabilityPlan: string;
+ funding: string;
+ problemBeingSolved: string;
+ measuredImpact: string;
+ successMetrics: string;
+ ecosystemFit: string;
+ communityFeedback: string;
+ openSourceLicense: string;
+
+ // Additional Details
+ repeatApplicant: boolean;
+ referral: string;
+ additionalInfo?: string;
+ opportunityOutreachConsent: boolean;
+
+ // Required for submission
+ captchaToken: string;
+ };
+}
+
+const handler = async (req: WishlistApiRequest, res: NextApiResponse) => {
+ const fields = { ...req.fields, ...req.files };
+
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' });
+ }
+
+ // validate fields against the schema
+ const result = WishlistSchema.safeParse(fields);
+ if (!result.success) {
+ const formatted = result.error.format();
+ console.error(formatted);
+
+ res.status(500).end();
+ return;
+ }
+
+ try {
+ const applicationData = {
+ // Contact Information
+ Application_FirstName__c: result.data.firstName,
+ Application_LastName__c: result.data.lastName,
+ Application_Email__c: result.data.email,
+ Application_Company__c: result.data.company,
+ Application_ProfileType__c: result.data.profileType,
+ Application_Other_ProfileType__c: result.data.otherProfileType,
+ Application_Alternative_Contact__c: result.data.alternativeContact,
+ Application_Website__c: result.data.website,
+ Application_Country__c: result.data.country,
+ Application_Time_Zone__c: result.data.timezone,
+ Application_Profile__c: result.data.applicantProfile,
+
+ // Project Overview
+ Name: result.data.projectName,
+ Application_ProjectDescription__c: result.data.projectSummary,
+ Application_ProjectRepo__c: result.data.projectRepo,
+ Application_Domain__c: result.data.domain,
+ Application_Output__c: result.data.output,
+ Application_RequestedAmount__c: result.data.budgetRequest,
+ CurrencyIsoCode: result.data.currency,
+
+ // Project Details
+ Application_ProjectStructure__c: result.data.projectStructure,
+ Application_SustainabilityPlan__c: result.data.sustainabilityPlan,
+ Application_OtherFunding__c: result.data.funding,
+ Application_Problem__c: result.data.problemBeingSolved,
+ Application_MeasuredImpact__c: result.data.measuredImpact,
+ Application_SuccessMetric__c: result.data.successMetrics,
+ Application_EcosystemFit__c: result.data.ecosystemFit,
+ Application_CommunityFeedback__c: result.data.communityFeedback,
+ Application_Open_Source_License_Picklist__c: result.data.openSourceLicense,
+
+ // Additional Details
+ Application_Repeat_Applicant__c: result.data.repeatApplicant,
+ Application_Referral__c: result.data.referral,
+ Application_AdditionalInformation__c: result.data.additionalInfo,
+ Application_OutreachConsent__c: result.data.opportunityOutreachConsent,
+
+ // Hardwired fields
+ Grant_Initiative__c: result.data.selectedWishlistId,
+ Application_Stage__c: 'New',
+ Application_Source__c: 'Webform',
+ RecordTypeId: '012Vj000008xEVPIA2'
+ };
+
+ const salesforceResult = await createSalesforceRecord('Application__c', applicationData);
+
+ // Handle file upload if present
+ if (result.data.fileUpload) {
+ const uploadProposal = result.data.fileUpload as File;
+
+ // Validate file object has required properties
+ if (!uploadProposal.filepath || !uploadProposal.originalFilename) {
+ console.error('Invalid file object:', uploadProposal);
+ res.status(400).json({ error: 'Invalid file upload' });
+ return;
+ }
+
+ try {
+ await uploadFileToSalesforce(
+ uploadProposal,
+ salesforceResult.id,
+ '[PROPOSAL]',
+ result.data.projectName
+ );
+ console.log('File uploaded successfully');
+ } catch (error) {
+ console.error('Error uploading proposal:', error);
+ res.status(500).json({ error: 'Failed to upload proposal' });
+ return;
+ }
+ }
+
+ console.log('Wishlist application submitted:', {
+ selectedWishlistId: result.data.selectedWishlistId,
+ projectName: result.data.projectName,
+ applicant: `${result.data.firstName} ${result.data.lastName}`,
+ email: result.data.email,
+ salesforceId: salesforceResult.id
+ });
+
+ const csatToken = generateCSATToken(salesforceResult.id);
+
+ res.status(200).json({
+ success: true,
+ message: 'Wishlist application submitted successfully',
+ applicationId: salesforceResult.id,
+ csatToken
+ });
+ } catch (error) {
+ console.error('Error submitting wishlist application:', error);
+ res.status(500).json({ error: 'Failed to submit application' });
+ }
+};
+
+export const config = {
+ api: {
+ bodyParser: false
+ }
+};
+
+export default multipartyParse(sanitizeFields(verifyCaptcha(handler)), {
+ maxFileSize: MAX_WISHLIST_FILE_SIZE
+});
diff --git a/src/pages/applicants/index.tsx b/src/pages/applicants/index.tsx
index 655e37d1..101389c8 100644
--- a/src/pages/applicants/index.tsx
+++ b/src/pages/applicants/index.tsx
@@ -1,28 +1,35 @@
-import { Box, Center, Flex, Link, Stack } from '@chakra-ui/react';
+import { Accordion, Box, Flex, Link, ListItem, Stack } from '@chakra-ui/react';
import { useInView } from 'react-intersection-observer';
import type { NextPage } from 'next';
-import Image from 'next/image';
import {
ApplicantsSidebar,
+ FAQItem,
+ List,
PageMetadata,
PageSection,
PageSubheading,
- PageText
+ PageText,
+ ProcessStep,
} from '../../components/UI';
-import softwareDevelopersSVG from '../../../public/images/software-developers-vector.svg';
-import researchersSVG from '../../../public/images/researchers-vector.svg';
-import academicsSVG from '../../../public/images/academics-vector.svg';
-import communityOrganizersSVG from '../../../public/images/community-organizers-vector.svg';
+import SupportTeamCards from '../../components/UI/common/SupportTeamCards';
-import { SIDEBAR_APPLICANTS_LINKS } from '../../constants';
+import {
+ ESP_TWITTER_URL,
+ ESP_FARCASTER_URL,
+ ESP_LENS_URL,
+ ESP_BLUESKY_URL,
+ SIDEBAR_APPLICANTS_LINKS,
+} from '../../constants';
const Applicants: NextPage = () => {
// `threshold` option allows us to control the % of visibility required before triggering the Intersection Observer
// https://react-intersection-observer.vercel.app/?path=/story/introduction--page#options
const [ref, inView] = useInView({ threshold: 0.3 });
const [ref2, inView2] = useInView({ threshold: 0, initialInView: false });
+ const [ref3, inView3] = useInView({ threshold: 0, initialInView: false });
+ const [ref4, inView4] = useInView({ threshold: 0, initialInView: false });
return (
<>
@@ -35,134 +42,109 @@ const Applicants: NextPage = () => {
-
- ESP Support Overview
-
+
- Mission and Scope
-
- ESP is in the process of refining our priorities and approach, aligning with the
- Ethereum Foundation's updated{' '}
-
- ecosystem development strategy
-
- . Learn more in our{' '}
-
- blog post
- {' '}
- and stay tuned for news on ESP's revised strategy, coming Q4 2025!
-
-
+ Mission and Scope
+
- ESP provides support to eligible projects working to improve Ethereum. We focus on
- work that strengthens Ethereum's foundations and enables future builders,
- such as open-source tooling, building blocks and libraries, research, community
- building, educational resources, open standards, infrastructure, and protocol
- improvements.
+ ESP provides financial and/or non-financial support to eligible projects working to improve Ethereum. We focus on work that strengthens Ethereum's foundations and enables future builders. The work we support is free, open-source, non-commercial, and designed to create positive sum outcomes for the community.
-
- ESP support is generally directed toward enabling builders rather than end-users:
- strengthening Ethereum's infrastructure, expanding the range of tools
- available to those building on Ethereum, gaining a deeper understanding of
- cryptographic primitives, and growing the builder ecosystem through education and
- community development. We're open to supporting work from people and teams of
- all kinds.
-
-
-
-
-
-
-
-
-
- Software and protocol developers
-
-
-
-
-
-
-
-
- Researchers
-
-
-
-
-
-
-
-
-
-
- Academics
-
-
-
-
-
-
-
-
- Community organizers
-
-
-
-
-
-
- We don't often support dapps or front-end platforms, although this is not a
- hard rule and there are exceptions—for example, where an application serves as a
- research or educational tool, or a reference implementation of a new standard.
+
+ Our support is generally directed toward enabling builders rather than end-users: strengthening Ethereum's infrastructure, expanding the range of tools available to those building on Ethereum, gaining a deeper understanding of cryptographic primitives, growing the builder ecosystem through education and community development, etc.
-
-
-
- How we support
+
+ Process
+
+
+
+ Browse the available Wishlist or RFP items and find one that matches your interests and expertise. Each item includes a detailed description and, when applicable, relevant resources.
+
+
+ Submit your application detailing how you plan to address the Wishlist or RFP item. Describe how your background and approach align with the project requirements by providing clear information on your methodology, timeline, and deliverables. Once submitted, you will receive a confirmation email.
+
+
+ The Grant Management (GM) team reviews applications in collaboration with the EF team responsible for the corresponding Wishlist or RFP item. The evaluation process may include: an interview to discuss your proposal in detail, project rescoping, or budget negotiations.
+
+
+ You will be notified of funding decisions via email. If your project is selected, we will work closely with you to establish a clear grant structure with milestone-based payments. All grant recipients also complete an onboarding process, involving KYC verification and signing a legal grant agreement.
+
+
+ Begin work on your project with ongoing support from the GM team. You will be paired with a Grant Evaluator for regular check-ins, milestone reviews, and support as you progress.
+
+
+ Once the scope of work is completed, you will share the results publicly in a report or post.
+
+
+
+
+
+ Selection criteria
- The Ethereum Foundation has many valuable resources: visibility, access to a
- massive collective knowledge base, a creative and dedicated team, along with
- connections to leading developers, researchers, and community members. Our goal is
- to make the most meaningful use of these resources. Builders can book an Office
- Hours session for non-financial support such as project feedback, advice, or help
- navigating the ecosystem.
+ Applications are evaluated based on several key factors, such as:
+
+
+
+ Technical approach : Soundness, feasibility, and clarity of your proposed methodology and implementation plan
+
+
+ Ecosystem impact : Potential benefit to the Ethereum community and alignment with ecosystem priorities
+
+
+ Open source : All outputs must be open-source or otherwise freely available; for-profit companies are welcome to apply, but the specific grant-funded work must be open-source and accessible to the community
+
+
+ Budget : Cost-effectiveness and appropriate allocation of resources; since we are offering non-dilutive capital, we generally anticipate some flexibility below standard market rates
+
+
+ Experience : Relevant background, skills, and prior work that demonstrate capability
+
+
+ Alignment : Understanding of Ethereum's values and ecosystem needs, and how your work contributes to them
+
+
+
+
+ FAQ
+
+
+
+
+ Wishlist items identify key gaps and opportunities within the ecosystem. Instead of prescribing specific approaches, it invites builders to propose ideas and initiatives that address these priorities. On the other hand, RFP items define focused problems and invites applicants to propose solutions. They are more prescriptive and time-bound than Wishlist items, focusing on a clear deliverable or outcome to be achieved.
+
+
+
+
+ You can apply for multiple items, but we recommend focusing on those that best match your expertise. If applying for multiple items, please submit separate applications for each.
+
+
+
+
+ After submitting your application, you will receive a confirmation email. Most applications are reviewed within 3-6 weeks, though the exact timeline may vary depending on the complexity of the Wishlist or RFP item and the details of the application.
+
+
+
+
+ Items are added based on the evolving needs and strategic priorities of the Ethereum ecosystem. To stay up to date, follow ESP on X (Twitter), Farcaster, Lens, or Bluesky, or check back periodically for updates on new opportunities.
+
+
+
+
+ Grant funding is determined based on the scope and complexity of the proposal, unless otherwise specified in the posted Wishlist or RFP item. Where applicable, RFPs may include budget guidance estimates. The GM team will work with applicants to determine final grant amounts.
+
+
+
+
+
+
diff --git a/src/pages/applicants/office-hours/index.tsx b/src/pages/applicants/office-hours/index.tsx
index 56ee1b66..8e7fab8b 100644
--- a/src/pages/applicants/office-hours/index.tsx
+++ b/src/pages/applicants/office-hours/index.tsx
@@ -21,9 +21,10 @@ import {
ETHEREUM_BROAD_ECOSYSTEM_URL,
ETHEREUM_JOBS_URL,
ETHEREUM_ORG_URL,
- ABOUT_URL,
+ APPLICANTS_URL,
SIDEBAR_OFFICE_HOURS_LINKS,
- OFFICE_HOURS_APPLY_URL
+ OFFICE_HOURS_APPLY_URL,
+ FOUNDER_SUCCESS_URL
} from '../../../constants';
const OfficeHours: NextPage = () => {
@@ -77,7 +78,7 @@ const OfficeHours: NextPage = () => {
common call topics include:
- Project feedback or guidance
+ Project feedback
If you're looking to maximize the impact of your project, we may be able to
offer guidance such as idea validation, help thinking through a roadmap, or
@@ -85,13 +86,18 @@ const OfficeHours: NextPage = () => {
Help navigating the Ethereum ecosystem
-
+
We like to think we know the Ethereum ecosystem pretty well. If you're
feeling lost, we may be able to help point you in the right direction by
identifying resources you might not be aware of, other projects tackling similar
problems, communities and events to consider participating in, or even other
potential sources of funding.
+
+ Guidance on project alignment
+
+ Thinking about submitting an application for a Wishlist or RFP item, but not certain if your project fits? We'd be happy to offer friendly advice and help you see if it could be a good match.
+
@@ -116,7 +122,7 @@ const OfficeHours: NextPage = () => {
scope
@@ -250,6 +256,9 @@ const OfficeHours: NextPage = () => {
.
+
+ If you're a founder seeking access to programs, mentorship, and visibility across the Ethereum ecosystem: click here.
+
diff --git a/src/pages/applicants/office-hours/thank-you.tsx b/src/pages/applicants/office-hours/thank-you.tsx
index 19630225..261123b1 100644
--- a/src/pages/applicants/office-hours/thank-you.tsx
+++ b/src/pages/applicants/office-hours/thank-you.tsx
@@ -1,10 +1,15 @@
import { Box, Heading, Link, Stack } from '@chakra-ui/react';
import type { NextPage } from 'next';
import Head from 'next/head';
+import { useRouter } from 'next/router';
import { PageMetadata, PageSubheading, PageText } from '../../../components/UI';
+import { CSATForm } from '../../../components/forms';
const OfficeHoursThankYou: NextPage = () => {
+ const router = useRouter();
+ const { applicationId, csatToken } = router.query;
+
return (
<>
@@ -40,9 +45,20 @@ const OfficeHoursThankYou: NextPage = () => {
_hover={{ textDecoration: 'none' }}
>
Office Hours About page
- .
+
+ .
+
+ {/* CSAT Survey */}
+ {applicationId && csatToken && (
+
+ )}
>
diff --git a/src/pages/applicants/rfp/[item]/apply.tsx b/src/pages/applicants/rfp/[item]/apply.tsx
new file mode 100644
index 00000000..676779cf
--- /dev/null
+++ b/src/pages/applicants/rfp/[item]/apply.tsx
@@ -0,0 +1,83 @@
+import { Box, Stack } from '@chakra-ui/react';
+import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+
+import {
+ PageMetadata,
+ PageSubheading,
+ PageText,
+ PrivacyPolicyAgreement
+} from '../../../../components/UI';
+import { RFPForm } from '../../../../components/forms/RFPForm';
+import { RFPItem } from '../../../../components/forms/schemas/RFP';
+import { getGrantInitiativeItems } from '../../../../lib/sf';
+
+interface RFPItemApplyProps {
+ rfpItem: RFPItem;
+}
+
+const RFPApply: NextPage = ({ rfpItem }) => {
+ return (
+ <>
+
+
+
+
+
+
+ Apply for {rfpItem.Name}
+
+
+
+ Please review the RFP requirements carefully and submit a comprehensive proposal that
+ demonstrates your expertise and approach. Include detailed methodology, timeline, and
+ expected deliverables in your PDF proposal.
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const rfpItems = await getGrantInitiativeItems('RFP');
+
+ const paths = rfpItems.map(item => ({
+ params: { item: item.Custom_URL_Slug__c || item.Id }
+ }));
+
+ return {
+ paths,
+ fallback: "blocking"
+ };
+};
+
+export const getStaticProps: GetStaticProps = async ({ params }) => {
+ const itemId = params?.item as string;
+ const rfpItems = await getGrantInitiativeItems('RFP');
+ const rfpItem = rfpItems.find(item => item.Custom_URL_Slug__c === itemId || item.Id === itemId);
+
+ if (!rfpItem) {
+ return {
+ notFound: true
+ };
+ }
+
+ return {
+ props: {
+ rfpItem
+ },
+ revalidate: 3600 // Revalidate every hour (3600 seconds)
+ };
+};
+
+export default RFPApply;
diff --git a/src/pages/applicants/rfp/[item]/index.tsx b/src/pages/applicants/rfp/[item]/index.tsx
new file mode 100644
index 00000000..6322a89e
--- /dev/null
+++ b/src/pages/applicants/rfp/[item]/index.tsx
@@ -0,0 +1,199 @@
+import { Box, Center, Heading, Stack, Text, Wrap, WrapItem, Tag } from '@chakra-ui/react';
+import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+
+import { PageMetadata } from '../../../../components/UI';
+import { RFPItem } from '../../../../components/forms/schemas/RFP';
+import { ButtonLink } from '../../../../components/ButtonLink';
+import { getGrantInitiativeItems } from '../../../../lib/sf';
+import parseStringForUrls from '../../../../utils/parseStringForUrls';
+
+interface RFPItemApplyProps {
+ rfpItem: RFPItem;
+}
+
+const RFPItemPage: NextPage = ({ rfpItem }) => {
+ const parseResources = (resources?: string) => {
+ if (!resources) return null;
+ return parseStringForUrls(resources);
+ };
+
+ const parseTags = (tags?: string) =>
+ tags
+ ?.split(';')
+ .map(tag => tag.trim())
+ .filter(Boolean) ?? [];
+
+ const formatDate = (value?: string) => {
+ if (!value) return undefined;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {rfpItem.Name}
+
+
+ {rfpItem.Description__c}
+
+ {rfpItem.Expected_Deliverables__c && (
+
+
+ Expected Deliverables
+
+ {rfpItem.Expected_Deliverables__c}
+
+ )}
+ {rfpItem.Tags__c && (
+
+
+ Tags
+
+
+ {parseTags(rfpItem.Tags__c).map(tag => (
+
+
+ {tag}
+
+
+ ))}
+
+
+ )}
+ {rfpItem.Ecosystem_Need__c && (
+
+
+ Ecosystem Need
+
+
+ {rfpItem.Ecosystem_Need__c}
+
+
+ )}
+ {rfpItem.RFP_HardRequirements_Long__c && (
+
+
+ Hard Requirements
+
+
+ {rfpItem.RFP_HardRequirements_Long__c}
+
+
+ )}
+ {rfpItem.RFP_SoftRequirements__c && (
+
+
+ Soft Requirements
+
+
+ {rfpItem.RFP_SoftRequirements__c}
+
+
+ )}
+ {rfpItem.Resources__c && (
+
+
+ Resources
+
+
+ {parseResources(rfpItem.Resources__c)}
+
+
+ )}
+ {(rfpItem.RFP_Open_Date__c ||
+ rfpItem.RFP_Close_Date__c ||
+ rfpItem.RFP_Project_Duration__c) && (
+
+
+ Timeline
+
+
+ {rfpItem.RFP_Open_Date__c && (
+ Opens: {formatDate(rfpItem.RFP_Open_Date__c)}
+ )}
+ {rfpItem.RFP_Close_Date__c && (
+ Closes: {formatDate(rfpItem.RFP_Close_Date__c)}
+ )}
+ {rfpItem.RFP_Project_Duration__c && (
+ Estimated Project Duration: {rfpItem.RFP_Project_Duration__c}
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const rfpItems = await getGrantInitiativeItems('RFP');
+
+ const paths = rfpItems.map(item => ({
+ params: { item: item.Custom_URL_Slug__c || item.Id }
+ }));
+
+ return {
+ paths,
+ fallback: 'blocking'
+ };
+};
+
+export const getStaticProps: GetStaticProps = async ({ params }) => {
+ const itemId = params?.item as string;
+
+ const rfpItems = await getGrantInitiativeItems('RFP');
+ const rfpItem = rfpItems.find(item => item.Custom_URL_Slug__c === itemId || item.Id === itemId);
+
+ if (!rfpItem) {
+ return {
+ notFound: true
+ };
+ }
+
+ return {
+ props: {
+ rfpItem
+ },
+ revalidate: 3600 // Revalidate every hour (3600 seconds)
+ };
+};
+
+export default RFPItemPage;
diff --git a/src/pages/applicants/rfp/index.tsx b/src/pages/applicants/rfp/index.tsx
new file mode 100644
index 00000000..21eb2eb9
--- /dev/null
+++ b/src/pages/applicants/rfp/index.tsx
@@ -0,0 +1,94 @@
+import { Box, Flex, Stack } from '@chakra-ui/react';
+import { useInView } from 'react-intersection-observer';
+import type { GetStaticProps, NextPage } from 'next';
+import { useSearchParams } from 'next/navigation';
+
+import {
+ ApplicantsSidebar,
+ PageSection,
+ PageSubheading,
+ PageText,
+ PageMetadata,
+ PrivacyPolicyAgreement,
+ ApplicationAttentionMsg
+} from '../../../components/UI';
+
+import { SIDEBAR_RFP_LINKS } from '../../../constants';
+import { RFPSelection } from '../../../components/forms/RFPSelection';
+import { getGrantInitiativeItems } from '../../../lib/sf';
+import { RFPItem } from '../../../components/forms/schemas/RFP';
+
+interface RFPProps {
+ rfpItems: RFPItem[];
+}
+
+const RFP: NextPage = ({ rfpItems }) => {
+ const [ref, inView] = useInView({ threshold: 0.5 });
+ const [ref2, inView2] = useInView({ threshold: 0, initialInView: false });
+
+ const tags = useSearchParams().get('tags')?.split(',');
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ Request for Proposals (RFPs)
+
+
+ RFPs define focused problems and outline clear target outcomes. They are more
+ prescriptive and time-bound, providing opportunities for specific research,
+ development, or implementation efforts that address identified needs within the
+ Ethereum ecosystem.
+
+
+
+
+
+
+
+
+ Apply
+
+
+ Select from the available RFP items below and submit your application. Please
+ review the item details carefully and explain how your background and approach
+ align with the project requirements. Provide clear information on your
+ methodology, timeline, and deliverables.
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const getStaticProps: GetStaticProps = async () => {
+ const rfpItems = await getGrantInitiativeItems('RFP');
+
+ return {
+ props: {
+ rfpItems
+ },
+ revalidate: 3600 // Revalidate every hour (3600 seconds)
+ };
+};
+
+export default RFP;
diff --git a/src/pages/applicants/rfp/thank-you.tsx b/src/pages/applicants/rfp/thank-you.tsx
new file mode 100644
index 00000000..f19f6fb7
--- /dev/null
+++ b/src/pages/applicants/rfp/thank-you.tsx
@@ -0,0 +1,85 @@
+import { Flex, Link, Stack } from '@chakra-ui/react';
+import type { NextPage } from 'next';
+import { useRouter } from 'next/router';
+
+import { PageMetadata, PageSubheading, PageText } from '../../../components/UI';
+import { ESP_EMAIL_ADDRESS, APPLICANTS_URL } from '../../../constants';
+import { CSATForm } from '../../../components/forms';
+
+const RFPThankYou: NextPage = () => {
+ const router = useRouter();
+ const { applicationId, csatToken } = router.query;
+
+ return (
+ <>
+
+
+
+
+
+ Thank you for your RFP application!
+
+
+
+ We have received your RFP application and appreciate your interest in contributing to
+ the Ethereum ecosystem.
+
+
+
+ You'll receive a confirmation email shortly. Our team will review your application
+ and reach out via email in due course. You can learn more about the evaluation process
+ on our{' '}
+
+ How to Apply page
+
+ .
+
+
+
+ If you have any questions about your application or the review process, please
+ don't hesitate to contact us at{' '}
+
+ {ESP_EMAIL_ADDRESS}
+
+ .
+
+
+
+ {/* CSAT Survey */}
+ {applicationId && csatToken && (
+
+ )}
+
+ >
+ );
+};
+
+export default RFPThankYou;
diff --git a/src/pages/applicants/wishlist/[item]/apply.tsx b/src/pages/applicants/wishlist/[item]/apply.tsx
new file mode 100644
index 00000000..1f55a261
--- /dev/null
+++ b/src/pages/applicants/wishlist/[item]/apply.tsx
@@ -0,0 +1,84 @@
+import { Box, Stack } from '@chakra-ui/react';
+import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+
+import {
+ PageMetadata,
+ PageSubheading,
+ PageText,
+ PrivacyPolicyAgreement
+} from '../../../../components/UI';
+import { WishlistForm } from '../../../../components/forms/WishlistForm';
+import { WishlistItem } from '../../../../components/forms/schemas/Wishlist';
+import { getGrantInitiativeItems } from '../../../../lib/sf';
+
+interface WishlistItemApplyProps {
+ wishlistItem: WishlistItem;
+}
+
+const WishlistApply: NextPage = ({ wishlistItem }) => {
+ return (
+ <>
+
+
+
+
+
+
+ Apply for {wishlistItem.Name}
+
+
+
+ Please review the item details carefully and explain how your background and approach
+ align with the requirements. Be specific about your methodology, timeline, and
+ expected deliverables.
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const wishlistItems = await getGrantInitiativeItems('Wishlist');
+
+ const paths = wishlistItems.map(item => ({
+ params: { item: item.Custom_URL_Slug__c || item.Id }
+ }));
+
+ return {
+ paths,
+ fallback: "blocking"
+ };
+};
+
+export const getStaticProps: GetStaticProps = async ({ params }) => {
+ const itemId = params?.item as string;
+
+ const wishlistItems = await getGrantInitiativeItems('Wishlist');
+ const wishlistItem = wishlistItems.find(item => item.Custom_URL_Slug__c === itemId || item.Id === itemId);
+
+ if (!wishlistItem) {
+ return {
+ notFound: true
+ };
+ }
+
+ return {
+ props: {
+ wishlistItem
+ },
+ revalidate: 3600 // Revalidate every hour (3600 seconds)
+ };
+};
+
+export default WishlistApply;
diff --git a/src/pages/applicants/wishlist/[item]/index.tsx b/src/pages/applicants/wishlist/[item]/index.tsx
new file mode 100644
index 00000000..d56d6c88
--- /dev/null
+++ b/src/pages/applicants/wishlist/[item]/index.tsx
@@ -0,0 +1,150 @@
+import { Box, Center, Heading, Stack, Text, Wrap, WrapItem, Tag } from '@chakra-ui/react';
+import type { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+
+import { PageMetadata } from '../../../../components/UI';
+import { WishlistItem } from '../../../../components/forms/schemas/Wishlist';
+import { ButtonLink } from '../../../../components/ButtonLink';
+import { getGrantInitiativeItems } from '../../../../lib/sf';
+import parseStringForUrls from '../../../../utils/parseStringForUrls';
+
+interface WishlistItemApplyProps {
+ wishlistItem: WishlistItem;
+}
+
+const WishlistItemPage: NextPage = ({ wishlistItem }) => {
+ const parseResources = (resources?: string) => {
+ if (!resources) return null;
+ return parseStringForUrls(resources);
+ };
+
+ const parseTags = (tags?: string) =>
+ tags
+ ?.split(';')
+ .map(tag => tag.trim())
+ .filter(Boolean) ?? [];
+
+ return (
+ <>
+
+
+
+
+
+
+ {wishlistItem.Name}
+
+
+ {wishlistItem.Description__c}
+
+ {wishlistItem.Expected_Deliverables__c && (
+
+
+ Expected Deliverables
+
+ {wishlistItem.Expected_Deliverables__c}
+
+ )}
+ {wishlistItem.Tags__c && (
+
+
+ Tags
+
+
+ {parseTags(wishlistItem.Tags__c).map(tag => (
+
+
+ {tag}
+
+
+ ))}
+
+
+ )}
+ {wishlistItem.Out_of_Scope__c && (
+
+
+ Out of Scope
+
+
+ {wishlistItem.Out_of_Scope__c}
+
+
+ )}
+ {wishlistItem.Resources__c && (
+
+
+ Resources
+
+
+ {parseResources(wishlistItem.Resources__c)}
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const wishlistItems = await getGrantInitiativeItems('Wishlist');
+
+ const paths = wishlistItems.map(item => ({
+ params: { item: item.Custom_URL_Slug__c || item.Id }
+ }));
+
+ return {
+ paths,
+ fallback: 'blocking'
+ };
+};
+
+export const getStaticProps: GetStaticProps = async ({ params }) => {
+ const itemId = params?.item as string;
+
+ const wishlistItems = await getGrantInitiativeItems('Wishlist');
+ const wishlistItem = wishlistItems.find(
+ item => item.Custom_URL_Slug__c === itemId || item.Id === itemId
+ );
+
+ if (!wishlistItem) {
+ return {
+ notFound: true
+ };
+ }
+
+ return {
+ props: {
+ wishlistItem
+ },
+ revalidate: 3600 // Revalidate every hour (3600 seconds)
+ };
+};
+
+export default WishlistItemPage;
diff --git a/src/pages/applicants/wishlist/index.tsx b/src/pages/applicants/wishlist/index.tsx
new file mode 100644
index 00000000..92770c81
--- /dev/null
+++ b/src/pages/applicants/wishlist/index.tsx
@@ -0,0 +1,94 @@
+import { Box, Flex, Stack } from '@chakra-ui/react';
+import { useInView } from 'react-intersection-observer';
+import type { GetStaticProps, NextPage } from 'next';
+import { useSearchParams } from 'next/navigation';
+
+import {
+ ApplicantsSidebar,
+ PageSection,
+ PageSubheading,
+ PageText,
+ PageMetadata,
+ PrivacyPolicyAgreement,
+ ApplicationAttentionMsg
+} from '../../../components/UI';
+
+import { SIDEBAR_WISHLIST_LINKS } from '../../../constants';
+import { WishlistSelection } from '../../../components/forms/WishlistSelection';
+import { WishlistItem } from '../../../components/forms/schemas/Wishlist';
+import { getGrantInitiativeItems } from '../../../lib/sf';
+
+interface WishlistProps {
+ wishlistItems: WishlistItem[];
+}
+
+const Wishlist: NextPage = ({ wishlistItems }) => {
+ const [ref, inView] = useInView({ threshold: 0.5 });
+ const [ref2, inView2] = useInView({ threshold: 0, initialInView: false });
+
+ const tags = useSearchParams().get('tags')?.split(',');
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ Wishlist Applications
+
+
+ The Wishlist identifies key gaps and opportunities within the ecosystem. Instead
+ of prescribing specific approaches, it invites builders to propose ideas and
+ initiatives that address these priorities.
+
+
+
+
+
+
+
+
+ Apply
+
+
+ Select from the available Wishlist items below and submit your application. Please
+ review the item details carefully and explain how your background and approach
+ align with the project requirements. Provide clear information on your
+ methodology, timeline, and deliverables.
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export const getStaticProps: GetStaticProps = async () => {
+ const wishlistItems = await getGrantInitiativeItems('Wishlist');
+
+ return {
+ props: { wishlistItems },
+ revalidate: 3600 // Revalidate every hour (3600 seconds)
+ };
+};
+
+export default Wishlist;
diff --git a/src/pages/applicants/wishlist/thank-you.tsx b/src/pages/applicants/wishlist/thank-you.tsx
new file mode 100644
index 00000000..e23c78d8
--- /dev/null
+++ b/src/pages/applicants/wishlist/thank-you.tsx
@@ -0,0 +1,85 @@
+import { Flex, Stack, Link } from '@chakra-ui/react';
+import type { NextPage } from 'next';
+import { useRouter } from 'next/router';
+
+import { PageMetadata, PageSubheading, PageText } from '../../../components/UI';
+import { APPLICANTS_URL, ESP_EMAIL_ADDRESS } from '../../../constants';
+import { CSATForm } from '../../../components/forms';
+
+const WishlistThankYou: NextPage = () => {
+ const router = useRouter();
+ const { applicationId, csatToken } = router.query;
+
+ return (
+ <>
+
+
+
+
+
+ Thank you for your Wishlist application!
+
+
+
+ We have received your Wishlist application and appreciate your interest in contributing
+ to the Ethereum ecosystem.
+
+
+
+ You'll receive a confirmation email shortly. Our team will review your application
+ and reach out via email in due course. You can learn more about the evaluation process
+ on our{' '}
+
+ How to Apply page
+
+ .
+
+
+
+ If you have any questions about your application or the review process, please
+ don't hesitate to contact us at{' '}
+
+ {ESP_EMAIL_ADDRESS}
+
+ .
+
+
+
+ {/* CSAT Survey */}
+ {applicationId && csatToken && (
+
+ )}
+
+ >
+ );
+};
+
+export default WishlistThankYou;
diff --git a/src/pages/form-direct/apply/index.tsx b/src/pages/form-direct/apply/index.tsx
new file mode 100644
index 00000000..a43ab631
--- /dev/null
+++ b/src/pages/form-direct/apply/index.tsx
@@ -0,0 +1,42 @@
+import { NextPage } from 'next';
+import { Box, VStack } from '@chakra-ui/react';
+
+import { DirectGrantForm } from '../../../components/forms/DirectGrantForm';
+import {
+ PageMetadata,
+ PageSubheading,
+ PageText,
+ PrivacyPolicyAgreement
+} from '../../../components/UI';
+
+const DirectGrantPage: NextPage = () => {
+ return (
+ <>
+
+
+
+
+
+
+ Direct Grant Application
+
+
+ Submit your application for a direct grant from the Ethereum Foundation Ecosystem
+ Support Program.
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default DirectGrantPage;
diff --git a/src/pages/form-direct/apply/thank-you.tsx b/src/pages/form-direct/apply/thank-you.tsx
new file mode 100644
index 00000000..50f844dd
--- /dev/null
+++ b/src/pages/form-direct/apply/thank-you.tsx
@@ -0,0 +1,65 @@
+import { NextPage } from 'next';
+import { Box, Text, VStack, Link } from '@chakra-ui/react';
+import { useRouter } from 'next/router';
+
+import { PageMetadata, PageSubheading } from '../../../components/UI';
+import { CSATForm } from '../../../components/forms';
+
+const DirectGrantThankYouPage: NextPage = () => {
+ const router = useRouter();
+ const { applicationId, csatToken } = router.query;
+
+ return (
+ <>
+
+
+
+
+ Thank You for Your Application!
+
+
+ Your direct grant application has been successfully submitted to the Ethereum Foundation
+ Ecosystem Support Program.
+
+
+
+ We have received your application and our team will review it carefully. You should
+ expect to hear back from us within a few weeks.
+
+
+
+ If you have any questions in the meantime, please don't hesitate to reach out to
+ us.
+
+
+
+
+ Return to Homepage
+
+
+
+
+ {/* CSAT Survey */}
+ {applicationId && csatToken && (
+
+ )}
+
+ >
+ );
+};
+
+export default DirectGrantThankYouPage;
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 40dd1034..6a737e28 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -1,14 +1,23 @@
-import { Box, Flex, Grid, GridItem, Stack } from '@chakra-ui/react';
+import {
+ Box,
+ Flex,
+ Grid,
+ GridItem,
+ ListItem,
+ Stack,
+ UnorderedList
+} from '@chakra-ui/react';
import type { NextPage } from 'next';
import Image from 'next/image';
import { HomeAboutCard, PageMetadata, PageSection, PageText } from '../components/UI';
+import SupportTeamCards from '../components/UI/common/SupportTeamCards';
+import applicantsHero from '../../public/images/applicants-hero.png';
import smallSucculentSVG from '../../public/images/small-succulent.svg';
import mediumSucculentSVG from '../../public/images/medium-succulent.svg';
import bigSucculentSVG from '../../public/images/big-succulent.svg';
import whatWeSupportTree from '../../public/images/what-we-support-tree.png';
-import whoWeSupportRoots from '../../public/images/who-we-support-roots.png';
import howWeSupportRoots from '../../public/images/how-we-support-roots.png';
import { ABOUT_URL, APPLICANTS_URL } from '../constants';
@@ -18,7 +27,7 @@ const Home: NextPage = () => {
<>
@@ -26,18 +35,15 @@ const Home: NextPage = () => {
- The Ethereum Foundation's Mission
+ Our Mission
- Our mission is to do what is best for Ethereum's long-term success. Our role
- is to allocate resources to critical projects , to be a valued
- voice within the Ethereum ecosystem, and to advocate for Ethereum to the outside
- world.
+ The Ecosystem Support Program is an ecosystem development cluster within the Ethereum Foundation comprising two teams: Grant Management and Funding Coordination. Together, we focus on strengthening Ethereum's foundations, supporting teams across the ecosystem, and enabling future builders.
-
+ {/*
Ecosystem Support Program's Role
@@ -48,7 +54,7 @@ const Home: NextPage = () => {
standards.
-
+ */}
@@ -210,49 +216,54 @@ const Home: NextPage = () => {
-
+
- We support free and open-source projects that strengthen Ethereum's
- foundations, with a particular focus on builder tools, infrastructure, research,
- community resources, and other public goods. Our support is generally directed
- towards builders, rather than end users.
+ ESP comprises two teams:
+
+
+ Grant Management: Supporting the strategic allocation of resources to projects that are most critical to Ethereum’s resilience and usability
+
+
+ Funding Coordination: Streamlining access to funding and resources across multiple channels to support impactful projects
+
+
-
+
- We have supported individuals and teams from all over the world representing
- different backgrounds, disciplines, and levels of experience. This includes
- companies, DAOs, non-profits, institutions, academics, developers, educators,
- community organizers, and more.
+ We support free and open-source projects that strengthen Ethereum's
+ foundations, with a particular focus on builder tools, infrastructure, research,
+ community resources, and other public goods. Our support is generally directed
+ towards builders, rather than end users.
-
+
{
width: 540,
height: 280.666
}}
- title='How We Support'
+ title='Our Approach'
link={APPLICANTS_URL}
>
- We aim to deploy resources where they will have the biggest impact. We try to keep
- our processes flexible and evolving in order to be open to new ideas and support
- builders of all kinds. Our Office Hours are an opportunity to explore a broad range
- of support through an informal conversation with a member of the ESP team, such as
- project feedback, advice, or help navigating the ecosystem.
+ Financial support is offered through our Wishlist and RFPs, which highlight funding opportunities curated by EF teams. Non-financial support is available through Office Hours, where builders can receive project feedback, guidance on navigating the ecosystem, or advice on aligning their project with a Wishlist or RFP item.
+
+
>
diff --git a/src/types.ts b/src/types.ts
index 0db3fa87..c02f5ea1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -323,25 +323,6 @@ export interface SidebarLink {
}
// body request data types
-export interface OfficeHoursNextApiRequest extends NextApiRequest {
- body: {
- firstName: string;
- lastName: string;
- email: string;
- individualOrTeam: string;
- company: string;
- officeHoursRequest: string;
- projectName: string;
- projectDescription: string;
- additionalInfo: string;
- projectCategory: string;
- otherReasonForMeeting: string;
- howDidYouHearAboutESP: string;
- country: string;
- timezone: string;
- };
-}
-
export interface GranteeFinanceNextApiRequest extends NextApiRequest {
body: {
beneficiaryName: string;
@@ -497,3 +478,41 @@ export interface PSESponsorshipsNextApiRequest extends NextApiRequest {
whyEthereum: string;
};
}
+
+export interface GrantInitiativeSalesforceRecord {
+ Id: string;
+ Name: string;
+ Description__c: string;
+ Status__c: string;
+ RecordTypeId: string;
+ Tags__c?: string;
+ Out_of_Scope__c?: string;
+ Resources__c?: string;
+ RFP_HardRequirements_Long__c?: string;
+ RFP_SoftRequirements__c?: string;
+ Ecosystem_Need__c?: string;
+ RFP_Project_Duration__c?: string; // Estimated Project Duration
+ RFP_Close_Date__c?: string;
+ RFP_Open_Date__c?: string;
+ Custom_URL_Slug__c?: string;
+}
+
+export type GrantInitiativeType = 'Wishlist' | 'RFP';
+
+export interface GrantInitiative {
+ Id: string;
+ Name: string;
+ Description__c: string;
+ Expected_Deliverables__c?: string;
+ Requirements__c?: string;
+ Tags__c?: string;
+ Out_of_Scope__c?: string;
+ Resources__c?: string;
+ Ecosystem_Need__c?: string;
+ RFP_Project_Duration__c?: string;
+ RFP_Close_Date__c?: string;
+ RFP_Open_Date__c?: string;
+ RFP_HardRequirements_Long__c?: string;
+ RFP_SoftRequirements__c?: string;
+ Custom_URL_Slug__c?: string;
+}
diff --git a/src/utils/parseStringForUrls.tsx b/src/utils/parseStringForUrls.tsx
new file mode 100644
index 00000000..beab26d9
--- /dev/null
+++ b/src/utils/parseStringForUrls.tsx
@@ -0,0 +1,28 @@
+import { Link } from "@chakra-ui/react";
+
+const parseStringForUrls = (text: string) => {
+ // URL regex pattern that matches http/https URLs
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+
+ const parts = text.split(urlRegex);
+
+ return parts.map((part, index) => {
+ if (urlRegex.test(part)) {
+ return (
+
+ {part}
+
+ );
+ }
+ return part;
+ });
+};
+
+export default parseStringForUrls;
\ No newline at end of file