Repository: terraware/terraware-web
Pull request #3756: SW-6616 Invite Funder
Author: login: ice1080
Created: 2025-03-27T21:45:06Z | Merged: 2025-03-28T18:28:41Z

Screenshot:
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/Gd93CNnCsYqInZuuYUOX/0e49a2fe-55a6-435c-b212-d7c625f0a79a.png)




Diff:
diff --git a/src/components/FundersTable/FunderCellRenderer.tsx b/src/components/FundersTable/FunderCellRenderer.tsx
index 587a4ed0bda0..45441211a3b2 100644
--- a/src/components/FundersTable/FunderCellRenderer.tsx
+++ b/src/components/FundersTable/FunderCellRenderer.tsx
@@ -16,7 +16,7 @@ export default function FunderCellRenderer(props: RendererProps<TableRowType>):
     return <CellRenderer index={index} column={column} value={name} row={row} />;
   } else if (column.key === 'dateAdded') {
     const date = getDate(row.createdTime).toFormat('yyyy-MM-dd');
-    const value = row.accountCreated ? date : activeLocale ? strings.INVITED : '';
+    const value = row.accountCreated ? `${strings.ADDED} ${date}` : activeLocale ? strings.INVITED : '';
     return <CellRenderer index={index} column={column} value={value} row={row} />;
   } else {
     return <CellRenderer {...props} />;
diff --git a/src/components/FundersTable/index.tsx b/src/components/FundersTable/index.tsx
index befa26a74adf..0ab632c00c85 100644
--- a/src/components/FundersTable/index.tsx
+++ b/src/components/FundersTable/index.tsx
@@ -1,9 +1,12 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 
-import { Grid, Typography } from '@mui/material';
-import { Button, TableRowType } from '@terraware/web-components';
+import { TableRowType } from '@terraware/web-components';
 
+import ClientSideFilterTable from 'src/components/Tables/ClientSideFilterTable';
+import Button from 'src/components/common/button/Button';
 import { TableColumnType } from 'src/components/common/table/types';
+import { APP_PATHS } from 'src/constants';
 import { requestListFunders } from 'src/redux/features/funder/fundingEntitiesAsyncThunks';
 import { selectListFundersRequest } from 'src/redux/features/funder/fundingEntitiesSelectors';
 import { useAppDispatch, useAppSelector } from 'src/redux/store';
@@ -12,7 +15,6 @@ import { Funder } from 'src/types/FundingEntity';
 import { SearchSortOrder } from 'src/types/Search';
 import useSnackbar from 'src/utils/useSnackbar';
 
-import ClientSideFilterTable from '../Tables/ClientSideFilterTable';
 import FunderCellRenderer from './FunderCellRenderer';
 import RemoveFunderTopBarButton from './RemoveFunderTopBarButton';
 
@@ -37,7 +39,7 @@ const columns = (activeLocale: string | null): TableColumnType[] =>
         },
         {
           key: 'dateAdded',
-          name: strings.DATE_ADDED,
+          name: strings.STATUS,
           type: 'string',
         },
       ]
@@ -50,6 +52,7 @@ type FundersTableProps = {
 const FundersTable = ({ fundingEntityId }: FundersTableProps) => {
   const dispatch = useAppDispatch();
   const snackbar = useSnackbar();
+  const navigate = useNavigate();
 
   const [listFundersRequestId, setListFundersRequestId] = useState<string>('');
   const [funders, setFunders] = useState<Funder[]>([]);
@@ -77,50 +80,42 @@ const FundersTable = ({ fundingEntityId }: FundersTableProps) => {
     }
   }, [listFundersResponse, setFunders, snackbar]);
 
-  return (
-    <>
-      <Grid container>
-        <Grid item xs={8}>
-          <Typography fontWeight={600} fontSize={'20px'} lineHeight={'28px'}>
-            {strings.PEOPLE}
-          </Typography>
-        </Grid>
-        <Grid
-          item
-          xs={4}
-          sx={{
-            display: 'flex',
-            flexDirection: 'column',
-            justifyContent: 'center',
-            alignItems: 'flex-end',
-          }}
-        >
-          <Button
-            id={'invite-funder'}
-            icon={'plus'}
-            priority={'secondary'}
-            onClick={() => snackbar.toastInfo('Invite funder functionality not yet implemented')}
-            label={strings.INVITE_FUNDER}
-            size='medium'
-          />
-        </Grid>
-      </Grid>
-
-      <ClientSideFilterTable
-        columns={columns}
-        defaultSortOrder={defaultSortOrder}
-        fuzzySearchColumns={fuzzySearchColumns}
-        id={'acceleratorPeopleTable'}
-        isClickable={() => false}
-        selectedRows={selectedRows}
-        setSelectedRows={setSelectedRows}
-        showCheckbox
-        showTopBar
-        Renderer={FunderCellRenderer}
-        rows={funders}
-        topBarButtons={[<RemoveFunderTopBarButton key={0} onConfirm={onRemoveConfirm} selectedRows={selectedRows} />]}
+  const goToInvitePage = useCallback(
+    () => navigate(APP_PATHS.ACCELERATOR_FUNDING_ENTITY_INVITE.replace(':fundingEntityId', fundingEntityId.toString())),
+    [fundingEntityId]
+  );
+
+  const rightComponent = useMemo(
+    () => (
+      <Button
+        label={strings.INVITE_FUNDER}
+        icon='plus'
+        onClick={goToInvitePage}
+        size='medium'
+        priority={'secondary'}
+        id='editFundingEntity'
       />
-    </>
+    ),
+    [goToInvitePage]
+  );
+
+  return (
+    <ClientSideFilterTable
+      columns={columns}
+      defaultSortOrder={defaultSortOrder}
+      fuzzySearchColumns={fuzzySearchColumns}
+      id={'fundersTable'}
+      isClickable={() => false}
+      selectedRows={selectedRows}
+      setSelectedRows={setSelectedRows}
+      showCheckbox
+      showTopBar
+      Renderer={FunderCellRenderer}
+      rows={funders}
+      title={strings.FUNDERS}
+      rightComponent={rightComponent}
+      topBarButtons={[<RemoveFunderTopBarButton key={0} onConfirm={onRemoveConfirm} selectedRows={selectedRows} />]}
+    />
   );
 };
 
diff --git a/src/constants.ts b/src/constants.ts
index afa65e5543ce..e1ba6deb4938 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -9,6 +9,10 @@ export const TERRAWARE_MOBILE_APP_ANDROID_GOOGLE_PLAY_LINK =
   'https://play.google.com/store/apps/details?id=com.terraformation.seedcollector';
 export const ACCELERATOR_LINK = 'https://www.terraformation.com/accelerator';
 
+// https://stackoverflow.com/questions/46155/whats-the-best-way-to-validate-an-email-address-in-javascript
+export const EMAIL_REGEX =
+  /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
 export enum APP_PATHS {
   ACCELERATOR = '/accelerator',
   ACCELERATOR_APPLICATION = '/accelerator/applications/:applicationId',
@@ -29,6 +33,7 @@ export enum APP_PATHS {
   ACCELERATOR_FUNDING_ENTITIES_EDIT = '/accelerator/funding-entities/:fundingEntityId/edit',
   ACCELERATOR_FUNDING_ENTITIES_VIEW = '/accelerator/funding-entities/:fundingEntityId',
   ACCELERATOR_FUNDING_ENTITIES_NEW = '/accelerator/funding-entities/new',
+  ACCELERATOR_FUNDING_ENTITY_INVITE = '/accelerator/funding-entities/:fundingEntityId/invite',
   ACCELERATOR_MODULES = '/accelerator/modules',
   ACCELERATOR_MODULE_CONTENT = '/accelerator/modules/:moduleId',
   ACCELERATOR_MODULE_EVENTS_EDIT = '/accelerator/modules/:moduleId/event/edit',
diff --git a/src/redux/features/funder/fundingEntitiesAsyncThunks.ts b/src/redux/features/funder/fundingEntitiesAsyncThunks.ts
index 9679f94ffa80..d7d3735b988a 100644
--- a/src/redux/features/funder/fundingEntitiesAsyncThunks.ts
+++ b/src/redux/features/funder/fundingEntitiesAsyncThunks.ts
@@ -68,8 +68,28 @@ export const requestCreateFundingEntity = createAsyncThunk(
   }
 );
 
+export const requestFundingEntityInviteFunder = createAsyncThunk(
+  'funding-entities/invite',
+  async (request: { fundingEntityId: number; email: string }, { rejectWithValue }) => {
+    const { fundingEntityId, email } = request;
+    const response = await FundingEntityService.inviteFunder(fundingEntityId, email);
+
+    if (response) {
+      if (response.requestSucceeded) {
+        return response.data;
+      } else if (response.errorDetails === 'PRE_EXISTING_USER') {
+        return rejectWithValue(strings.EMAIL_ALREADY_EXISTS);
+      } else if (response.errorDetails === 'INVALID_EMAIL') {
+        return rejectWithValue(strings.INCORRECT_EMAIL_FORMAT);
+      }
+    }
+
+    return rejectWithValue(strings.GENERIC_ERROR);
+  }
+);
+
 export const requestListFunders = createAsyncThunk(
-  'funding-entities/create',
+  'funding-entities/list',
   async (fundingEntityId: number, { rejectWithValue }) => {
     const response = await FundingEntityService.listFunders(fundingEntityId);
     if (response && response.requestSucceeded) {
diff --git a/src/redux/features/funder/fundingEntitiesSelectors.ts b/src/redux/features/funder/fundingEntitiesSelectors.ts
index 9ecd7591442b..c4be23092e38 100644
--- a/src/redux/features/funder/fundingEntitiesSelectors.ts
+++ b/src/redux/features/funder/fundingEntitiesSelectors.ts
@@ -19,3 +19,5 @@ export const selectListFundersRequest = (requestId: string) => (state: RootState
 
 export const selectFundingEntityFunders = (fundingEntityId?: number) => (state: RootState) =>
   state.funders[fundingEntityId || -1];
+
+export const inviteFunderRequest = (requestId: string) => (state: RootState) => state.fundingEntityInvite[requestId];
diff --git a/src/redux/features/funder/fundingEntitiesSlice.ts b/src/redux/features/funder/fundingEntitiesSlice.ts
index 7c60ccf01f58..b84db601ebe2 100644
--- a/src/redux/features/funder/fundingEntitiesSlice.ts
+++ b/src/redux/features/funder/fundingEntitiesSlice.ts
@@ -1,6 +1,7 @@
 import { createSlice } from '@reduxjs/toolkit';
 
 import { StatusT, buildReducers } from 'src/redux/features/asyncUtils';
+import { InviteFunderResponse } from 'src/services/FundingEntityService';
 import { Funder, FundingEntity } from 'src/types/FundingEntity';
 
 import {
@@ -8,6 +9,7 @@ import {
   requestFundingEntities,
   requestFundingEntity,
   requestFundingEntityForUser,
+  requestFundingEntityInviteFunder,
   requestListFunders,
   requestUpdateFundingEntity,
 } from './fundingEntitiesAsyncThunks';
@@ -22,6 +24,8 @@ const initialStateFundingEntityUpdate: { [requestId: string]: StatusT<{ fundingE
 
 const initialStateFundingEntityCreate: { [requestId: string]: StatusT<{ fundingEntity: FundingEntity }> } = {};
 
+const initialStateFundingEntityInvite: { [requestId: string]: StatusT<string | InviteFunderResponse> } = {};
+
 export const fundingEntitySlice = createSlice({
   name: 'fundingEntitySlice',
   initialState: initialStateFundingEntity,
@@ -67,6 +71,15 @@ export const fundingEntityCreateSlice = createSlice({
   },
 });
 
+export const fundingEntityInviteSlice = createSlice({
+  name: 'fundingEntityInviteSlice',
+  initialState: initialStateFundingEntityInvite,
+  reducers: {},
+  extraReducers: (builder) => {
+    buildReducers(requestFundingEntityInviteFunder, true)(builder);
+  },
+});
+
 const initialStateFunderList: { [requestId: string]: StatusT<Funder[]> } = {};
 
 export const funderListSlice = createSlice({
@@ -84,6 +97,7 @@ const fundingEntitiesReducers = {
   fundingEntities: fundingEntitiesSlice.reducer,
   fundingEntityUpdate: fundingEntityUpdateSlice.reducer,
   fundingEntityCreate: fundingEntityCreateSlice.reducer,
+  fundingEntityInvite: fundingEntityInviteSlice.reducer,
   funders: funderListSlice.reducer,
 };
 
diff --git a/src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx b/src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
new file mode 100644
index 000000000000..e436c8371180
--- /dev/null
+++ b/src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
@@ -0,0 +1,147 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Container, Grid, Typography, useTheme } from '@mui/material';
+import { Textfield } from '@terraware/web-components';
+
+import Page from 'src/components/Page';
+import Card from 'src/components/common/Card';
+import PageForm from 'src/components/common/PageForm';
+import { EMAIL_REGEX } from 'src/constants';
+import useNavigateTo from 'src/hooks/useNavigateTo';
+import { useFundingEntity } from 'src/providers';
+import { requestFundingEntityInviteFunder } from 'src/redux/features/funder/fundingEntitiesAsyncThunks';
+import { inviteFunderRequest } from 'src/redux/features/funder/fundingEntitiesSelectors';
+import { useAppDispatch, useAppSelector } from 'src/redux/store';
+import strings from 'src/strings';
+import { Funder } from 'src/types/FundingEntity';
+import useDeviceInfo from 'src/utils/useDeviceInfo';
+import useForm from 'src/utils/useForm';
+import useSnackbar from 'src/utils/useSnackbar';
+
+const InviteView = () => {
+  const { goToFundingEntity } = useNavigateTo();
+  const { fundingEntity, reload: reloadFundingEntity } = useFundingEntity();
+  const theme = useTheme();
+  const { isMobile } = useDeviceInfo();
+  const pathParams = useParams<{ fundingEntityId: string }>();
+  const fundingEntityId = Number(pathParams.fundingEntityId);
+  const [record, , onChange] = useForm<Partial<Funder>>({});
+  const [emailError, setEmailError] = useState('');
+  const snackbar = useSnackbar();
+  const dispatch = useAppDispatch();
+  const [requestId, setRequestId] = useState<string>('');
+  const inviteFunderResponse = useAppSelector(inviteFunderRequest(requestId));
+
+  const onCancel = useCallback(() => {
+    if (fundingEntityId) {
+      goToFundingEntity(fundingEntityId);
+    }
+  }, [fundingEntityId]);
+
+  const onSendClick = useCallback(() => {
+    if (!fundingEntityId) {
+      snackbar.toastError();
+      return;
+    }
+
+    if (!record.email || record.email === '') {
+      setEmailError(strings.REQUIRED_FIELD);
+      return;
+    }
+
+    if (!EMAIL_REGEX.test(record.email)) {
+      setEmailError(strings.INCORRECT_EMAIL_FORMAT);
+      return;
+    }
+
+    const request = dispatch(requestFundingEntityInviteFunder({ fundingEntityId, email: record.email }));
+    setRequestId(request.requestId);
+  }, [dispatch, snackbar, record.email, fundingEntityId]);
+
+  useEffect(() => {
+    if (!inviteFunderResponse || inviteFunderResponse.status === 'pending') return;
+
+    if (inviteFunderResponse.status === 'error') {
+      setEmailError(inviteFunderResponse.data as string);
+      return;
+    }
+
+    if (inviteFunderResponse.status === 'success') {
+      snackbar.toastSuccess(strings.FUNDER_ADDED);
+      reloadFundingEntity();
+    } else {
+      snackbar.toastError();
+    }
+    goToFundingEntity(fundingEntityId);
+  }, [inviteFunderResponse, snackbar]);
+
+  return (
+    <Page
+      title={strings.INVITE_FUNDER}
+      description={strings.INVITE_FUNDER_DESCRIPTION}
+      contentStyle={{ display: 'flex', flexDirection: 'column' }}
+    >
+      <PageForm
+        cancelID={'cancelInviteFunder'}
+        saveID={'saveInviteFunder'}
+        onCancel={onCancel}
+        onSave={onSendClick}
+        saveButtonText={strings.SEND}
+      >
+        <Container
+          maxWidth={false}
+          sx={{
+            display: 'flex',
+            margin: '0 auto',
+            paddingLeft: theme.spacing(isMobile ? 0 : 4),
+            paddingRight: theme.spacing(isMobile ? 0 : 4),
+            paddingTop: theme.spacing(5),
+            width: isMobile ? '100%' : '800px',
+          }}
+        >
+          <Card style={{ width: '800px', margin: 'auto' }}>
+            <Grid item xs={12}>
+              <Typography fontSize='20px' fontWeight={600} lineHeight='20px'>
+                {fundingEntity?.name}
+              </Typography>
+            </Grid>
+            <Grid item xs={12} sx={{ marginTop: theme.spacing(4) }}>
+              <Textfield
+                id='email'
+                label={strings.EMAIL}
+                type='text'
+                onChange={(value) => onChange('email', value)}
+                value={record.email}
+                required={true}
+                errorText={emailError}
+              />
+            </Grid>
+            <Grid item xs={12} sx={{ marginTop: theme.spacing(2) }}>
+              <Textfield
+                id='firstName'
+                label={strings.FIRST_NAME}
+                onChange={() => {}}
+                type='text'
+                value={'--'}
+                disabled
+              />
+            </Grid>
+            <Grid item xs={12} sx={{ marginTop: theme.spacing(2) }}>
+              <Textfield
+                id='lastName'
+                label={strings.LAST_NAME}
+                onChange={() => {}}
+                type='text'
+                value={'--'}
+                disabled
+              />
+            </Grid>
+          </Card>
+        </Container>
+      </PageForm>
+    </Page>
+  );
+};
+
+export default InviteView;
diff --git a/src/scenes/AcceleratorRouter/FundingEntities/SingleView.tsx b/src/scenes/AcceleratorRouter/FundingEntities/SingleView.tsx
index 0b894b680659..5f787d263671 100644
--- a/src/scenes/AcceleratorRouter/FundingEntities/SingleView.tsx
+++ b/src/scenes/AcceleratorRouter/FundingEntities/SingleView.tsx
@@ -77,7 +77,7 @@ const SingleView = () => {
             />
           )}
           <Card flushMobile style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, borderRadius: '24px' }}>
-            <Grid container spacing={3} paddingBottom={theme.spacing(8)}>
+            <Grid container spacing={3} paddingBottom={theme.spacing(4)}>
               <Grid item xs={4}>
                 <TextField label={strings.NAME} id='name' type='text' value={fundingEntity.name} display={true} />
               </Grid>
@@ -99,7 +99,9 @@ const SingleView = () => {
                 </Grid>
               ))}
             </Grid>
-            <FundersTable fundingEntityId={fundingEntity.id} />
+            <Grid container spacing={3}>
+              <FundersTable fundingEntityId={fundingEntity.id} />
+            </Grid>
           </Card>
         </Page>
       )}
diff --git a/src/scenes/AcceleratorRouter/FundingEntities/index.tsx b/src/scenes/AcceleratorRouter/FundingEntities/index.tsx
index 30a0ac752f0e..c33ad3ad6c92 100644
--- a/src/scenes/AcceleratorRouter/FundingEntities/index.tsx
+++ b/src/scenes/AcceleratorRouter/FundingEntities/index.tsx
@@ -3,6 +3,7 @@ import React, { Route, Routes } from 'react-router-dom';
 import FundingEntityProvider from 'src/providers/FundingEntityProvider';
 
 import EditView from './EditView';
+import InviteView from './InviteView';
 import FundingEntitiesListView from './ListView';
 import NewView from './NewView';
 import SingleView from './SingleView';
@@ -18,6 +19,7 @@ const FundingEntitiesRouter = () => {
             <Routes>
               <Route path={''} element={<SingleView />} />
               <Route path={'edit'} element={<EditView />} />
+              <Route path={'invite'} element={<InviteView />} />
             </Routes>
           </FundingEntityProvider>
         }
diff --git a/src/scenes/PeopleRouter/NewPersonView.tsx b/src/scenes/PeopleRouter/NewPersonView.tsx
index 81b7fe0d8a9f..a2d24fac92d7 100644
--- a/src/scenes/PeopleRouter/NewPersonView.tsx
+++ b/src/scenes/PeopleRouter/NewPersonView.tsx
@@ -9,7 +9,7 @@ import ErrorBox from 'src/components/common/ErrorBox/ErrorBox';
 import PageForm from 'src/components/common/PageForm';
 import TextField from 'src/components/common/Textfield/Textfield';
 import TfMain from 'src/components/common/TfMain';
-import { APP_PATHS } from 'src/constants';
+import { APP_PATHS, EMAIL_REGEX } from 'src/constants';
 import { useOrganization } from 'src/providers/hooks';
 import { OrganizationUserService } from 'src/services';
 import strings from 'src/strings';
@@ -85,12 +85,7 @@ export default function PersonView(): JSX.Element {
       return;
     }
 
-    // https://stackoverflow.com/questions/46155/whats-the-best-way-to-validate-an-email-address-in-javascript
-    if (
-      !/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
-        newPerson.email
-      )
-    ) {
+    if (!EMAIL_REGEX.test(newPerson.email)) {
       setEmailError(strings.INCORRECT_EMAIL_FORMAT);
       return;
     }
diff --git a/src/services/FundingEntityService.ts b/src/services/FundingEntityService.ts
index 9d7224373188..208bacb975f0 100644
--- a/src/services/FundingEntityService.ts
+++ b/src/services/FundingEntityService.ts
@@ -24,6 +24,13 @@ export type FundingEntitiesResponse = Response & FundingEntitiesData;
 export type UserFundingEntityResponse = Response & UserFundingEntityData;
 export type FundingEntityResponse = Response & FundingEntityData;
 
+export type InviteFunderError = 'PRE_EXISTING_USER' | 'INVALID_EMAIL';
+
+export type InviteFunderResponse = Response & {
+  email: string;
+  errorDetails?: InviteFunderError;
+};
+
 // endpoints
 const FUNDING_ENTITIES_LIST_ENDPOINT = '/api/v1/funder/entities';
 const FUNDING_ENTITY_ENDPOINT = '/api/v1/funder/entities/{fundingEntityId}';
@@ -47,6 +54,9 @@ export type UpdateFundingEntityRequest =
 export type CreateFundingEntityRequest =
   paths[typeof FUNDING_ENTITIES_LIST_ENDPOINT]['post']['requestBody']['content']['application/json'];
 
+export type InviteFunderServerResponse =
+  paths[typeof FUNDING_ENTITY_USERS_ENDPOINT]['post']['responses'][200]['content']['application/json'];
+
 const httpUserFundingEntity = HttpService.root(USER_FUNDING_ENTITY_ENDPOINT);
 const httpFundingEntity = HttpService.root(FUNDING_ENTITY_ENDPOINT);
 const httpFundingEntityUsers = HttpService.root(FUNDING_ENTITY_USERS_ENDPOINT);
@@ -138,6 +148,30 @@ const deleteFundingEntity = async (fundingEntityId: number): Promise<Response> =
   });
 };
 
+const inviteFunder = async (fundingEntityId: number, email: string): Promise<InviteFunderResponse> => {
+  const serverResponse: Response = await httpFundingEntityUsers.post({
+    entity: { email: email },
+    urlReplacements: {
+      '{fundingEntityId}': fundingEntityId.toString(),
+    },
+  });
+
+  const response: InviteFunderResponse = { ...serverResponse, email: '' };
+  if (response.requestSucceeded) {
+    const data: InviteFunderServerResponse = response.data;
+    response.email = data?.email;
+  } else {
+    if (response.statusCode === 409) {
+      // conflict
+      response.errorDetails = 'PRE_EXISTING_USER';
+    } else if (response.error === 'Field value has incorrect format: email') {
+      response.errorDetails = 'INVALID_EMAIL';
+    }
+  }
+
+  return response;
+};
+
 const FundingEntityService = {
   getUserFundingEntity,
   get,
@@ -146,6 +180,7 @@ const FundingEntityService = {
   create,
   deleteFundingEntity,
   listFunders,
+  inviteFunder,
 };
 
 export default FundingEntityService;
diff --git a/src/strings/csv/en.csv b/src/strings/csv/en.csv
index 329f8e26d809..8f20e3c6b223 100644
--- a/src/strings/csv/en.csv
+++ b/src/strings/csv/en.csv
@@ -92,6 +92,7 @@ ADD_TEST,Add Test,
 ADD_TO_PROJECT,Add to Project,
 ADD_VIABILITY,Add Viability,
 ADD_VIABILITY_TEST,Add Viability Test,
+ADDED,Added,
 ADDING_SUBZONE_BOUNDARIES,Adding Subzone Boundaries,
 ADDING_SUBZONE_BOUNDARIES_INSTRUCTIONS_DESCRIPTION,This video shows how to use the slice {0} tool to create subzone shapes (polygons) within the zones.,
 ADDING_ZONE_BOUNDARIES,Adding Zone Boundaries,
@@ -668,6 +669,8 @@ FROM_SUBZONE,From: Subzone,
 FROM_X,from {0},
 FRONT_OF_SEED_BANK,Front of Seed Bank,
 FUNDER,Funder,
+FUNDER_ADDED,Funder Added,
+FUNDERS,Funders,
 FUNDER_NAME,Funder Name,
 FUNDER_PORTAL,Funder Portal,
 FUNDING_ENTITIES,Funding Entities,
@@ -789,6 +792,7 @@ INVENTORY_PAGE_DESCRIPTION,Manage your Inventory.,
 INVESTMENT_COMMITTEE_VOTES,Investment Committee Votes,
 INVESTMENT_THESIS,Investment Thesis,
 INVITE_FUNDER,Invite Funder,
+INVITE_FUNDER_DESCRIPTION,Provide an email address to invite a Funder to the Funder Portal under this Funding Entity. An invitation email to create an account will be sent to the email address. First Name and Last Name will be provided by the account holder.,
 INVITED,Invited,
 ISSUE,Issue,
 IUCN_CRITICALLY_ENDANGERED,Critically Endangered (CR),IUCN conservation category. Keep the two-letter code as is; only translate the description.
@@ -1477,9 +1481,9 @@ REMOVE,Remove,
 REMOVE_ACCESS,Remove Access,
 REMOVE_ACCESS_MODAL_TEXT,This action will not remove people from Terraware. It will just remove their Accelerator Console access.,
 REMOVE_FUNDER,Remove Funder,
-REMOVE_FUNDER_MODAL_TEXT,Are you sure you want to remove {0}? Removing them means they won’t have access to your funding entity's data anymore.,
+REMOVE_FUNDER_MODAL_TEXT,Are you sure you want to remove {0}? Removing them means they won’t have access to this funding entity's data anymore.,
 REMOVE_FUNDERS,Remove Funders,
-REMOVE_FUNDERS_MODAL_TEXT,Are you sure you want to remove funders? Removing them means they won’t have access to your funding entity's data anymore.,
+REMOVE_FUNDERS_MODAL_TEXT,Are you sure you want to remove funders? Removing them means they won’t have access to this funding entity's data anymore.,
 REMOVE_ONLY_ONE_ORG_AT_A_TIME,You may only remove yourself from one organization at a time.,
 REMOVE_ORGANIZATION,Remove Organization?,
 REMOVE_ORGANIZATION_MODAL_TEXT,Are you sure you want to remove these organizations from the list of Accelerator Participants?,
@@ -1686,6 +1690,7 @@ SELECT_SEEDLINGS_BATCHES_FOR_PROJECT_DESCRIPTION,Use the checkbox column to sele
 SELECT_VOTE,Select Vote,
 SELECTED_SPECIES_IN_USE,Selected species are all in use and cannot be deleted.,
 SELECTED_SPECIES_UNUSED,Selected species are not in use and can be deleted.,
+SEND,Send,
 SEND_REQUEST,Send Request,
 SENSITIVE_QUESTION_MARK,Sensitive?,
 SENSOR_KIT,Sensor Kit,


Reviews:
user: login: tommylau523
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T02:15:31Z
user: login: tommylau523
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T02:15:59Z
user: login: tommylau523
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T02:16:50Z
user: login: ice1080
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:12:23Z
user: login: ice1080
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:21:59Z
user: login: tommylau523
body: Should this 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:20:48Z

Review comments:
diff_hunk: @@ -0,0 +1,143 @@
+import React, { useCallback, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Container, Grid, Typography, useTheme } from '@mui/material';
+import { Textfield } from '@terraware/web-components';
+
+import Page from 'src/components/Page';
+import Card from 'src/components/common/Card';
+import PageForm from 'src/components/common/PageForm';
+import { EMAIL_REGEX } from 'src/constants';
+import useNavigateTo from 'src/hooks/useNavigateTo';
+import { useFundingEntity } from 'src/providers';
+import FundingEntityService from 'src/services/FundingEntityService';
+import strings from 'src/strings';
+import { Funder } from 'src/types/FundingEntity';
+import useDeviceInfo from 'src/utils/useDeviceInfo';
+import useForm from 'src/utils/useForm';
+import useSnackbar from 'src/utils/useSnackbar';
+
+const InviteView = () => {
+  const { goToFundingEntity } = useNavigateTo();
+  const { fundingEntity, reload: reloadFundingEntity } = useFundingEntity();
+  const theme = useTheme();
+  const { isMobile } = useDeviceInfo();
+  const pathParams = useParams<{ fundingEntityId: string }>();
+  const fundingEntityId = Number(pathParams.fundingEntityId);
+  const [record, , onChange] = useForm<Partial<Funder>>({});
+  const [emailError, setEmailError] = useState('');
+  const snackbar = useSnackbar();
+
+  const onCancel = useCallback(() => {
+    if (fundingEntityId) {
+      goToFundingEntity(fundingEntityId);
+    }
+  }, [fundingEntityId]);
+
+  const onSendClick = async () => {
+    if (!fundingEntityId) {
+      snackbar.toastError();
+      return;
+    }
+
+    if (!record.email || record.email === '') {
+      setEmailError(strings.REQUIRED_FIELD);
+      return;
+    }
+
+    if (!EMAIL_REGEX.test(record.email)) {
+      setEmailError(strings.INCORRECT_EMAIL_FORMAT);
+      return;
+    }
+
+    let successMessage: string | null = null;
+    const response = await FundingEntityService.inviteFunder(fundingEntityId, record.email);
+    if (!response.requestSucceeded) {
+      if (response.errorDetails === 'PRE_EXISTING_USER') {
+        setEmailError(strings.EMAIL_ALREADY_EXISTS);
+        return;
+      } else if (response.errorDetails === 'INVALID_EMAIL') {
+        setEmailError(strings.INCORRECT_EMAIL_FORMAT);
+        return;
+      }
+    }
+
+    successMessage = response.requestSucceeded ? strings.PERSON_ADDED : null;
path: src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
user: login: tommylau523
body: Should this say FUNDER_ADDED instead?
created_at: 2025-03-28T02:15:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,143 @@
+import React, { useCallback, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Container, Grid, Typography, useTheme } from '@mui/material';
+import { Textfield } from '@terraware/web-components';
+
+import Page from 'src/components/Page';
+import Card from 'src/components/common/Card';
+import PageForm from 'src/components/common/PageForm';
+import { EMAIL_REGEX } from 'src/constants';
+import useNavigateTo from 'src/hooks/useNavigateTo';
+import { useFundingEntity } from 'src/providers';
+import FundingEntityService from 'src/services/FundingEntityService';
+import strings from 'src/strings';
+import { Funder } from 'src/types/FundingEntity';
+import useDeviceInfo from 'src/utils/useDeviceInfo';
+import useForm from 'src/utils/useForm';
+import useSnackbar from 'src/utils/useSnackbar';
+
+const InviteView = () => {
+  const { goToFundingEntity } = useNavigateTo();
+  const { fundingEntity, reload: reloadFundingEntity } = useFundingEntity();
+  const theme = useTheme();
+  const { isMobile } = useDeviceInfo();
+  const pathParams = useParams<{ fundingEntityId: string }>();
+  const fundingEntityId = Number(pathParams.fundingEntityId);
+  const [record, , onChange] = useForm<Partial<Funder>>({});
+  const [emailError, setEmailError] = useState('');
+  const snackbar = useSnackbar();
+
+  const onCancel = useCallback(() => {
+    if (fundingEntityId) {
+      goToFundingEntity(fundingEntityId);
+    }
+  }, [fundingEntityId]);
+
+  const onSendClick = async () => {
+    if (!fundingEntityId) {
+      snackbar.toastError();
+      return;
+    }
+
+    if (!record.email || record.email === '') {
+      setEmailError(strings.REQUIRED_FIELD);
+      return;
+    }
+
+    if (!EMAIL_REGEX.test(record.email)) {
+      setEmailError(strings.INCORRECT_EMAIL_FORMAT);
+      return;
+    }
+
+    let successMessage: string | null = null;
+    const response = await FundingEntityService.inviteFunder(fundingEntityId, record.email);
path: src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
user: login: tommylau523
body: should this be put into redux? We have been doing that instead of calling APIs directly. 
created_at: 2025-03-28T02:15:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,143 @@
+import React, { useCallback, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Container, Grid, Typography, useTheme } from '@mui/material';
+import { Textfield } from '@terraware/web-components';
+
+import Page from 'src/components/Page';
+import Card from 'src/components/common/Card';
+import PageForm from 'src/components/common/PageForm';
+import { EMAIL_REGEX } from 'src/constants';
+import useNavigateTo from 'src/hooks/useNavigateTo';
+import { useFundingEntity } from 'src/providers';
+import FundingEntityService from 'src/services/FundingEntityService';
+import strings from 'src/strings';
+import { Funder } from 'src/types/FundingEntity';
+import useDeviceInfo from 'src/utils/useDeviceInfo';
+import useForm from 'src/utils/useForm';
+import useSnackbar from 'src/utils/useSnackbar';
+
+const InviteView = () => {
+  const { goToFundingEntity } = useNavigateTo();
+  const { fundingEntity, reload: reloadFundingEntity } = useFundingEntity();
+  const theme = useTheme();
+  const { isMobile } = useDeviceInfo();
+  const pathParams = useParams<{ fundingEntityId: string }>();
+  const fundingEntityId = Number(pathParams.fundingEntityId);
+  const [record, , onChange] = useForm<Partial<Funder>>({});
+  const [emailError, setEmailError] = useState('');
+  const snackbar = useSnackbar();
+
+  const onCancel = useCallback(() => {
+    if (fundingEntityId) {
+      goToFundingEntity(fundingEntityId);
+    }
+  }, [fundingEntityId]);
+
+  const onSendClick = async () => {
path: src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
user: login: tommylau523
body: this should probably be a useCallback. 
created_at: 2025-03-28T02:16:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,143 @@
+import React, { useCallback, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Container, Grid, Typography, useTheme } from '@mui/material';
+import { Textfield } from '@terraware/web-components';
+
+import Page from 'src/components/Page';
+import Card from 'src/components/common/Card';
+import PageForm from 'src/components/common/PageForm';
+import { EMAIL_REGEX } from 'src/constants';
+import useNavigateTo from 'src/hooks/useNavigateTo';
+import { useFundingEntity } from 'src/providers';
+import FundingEntityService from 'src/services/FundingEntityService';
+import strings from 'src/strings';
+import { Funder } from 'src/types/FundingEntity';
+import useDeviceInfo from 'src/utils/useDeviceInfo';
+import useForm from 'src/utils/useForm';
+import useSnackbar from 'src/utils/useSnackbar';
+
+const InviteView = () => {
+  const { goToFundingEntity } = useNavigateTo();
+  const { fundingEntity, reload: reloadFundingEntity } = useFundingEntity();
+  const theme = useTheme();
+  const { isMobile } = useDeviceInfo();
+  const pathParams = useParams<{ fundingEntityId: string }>();
+  const fundingEntityId = Number(pathParams.fundingEntityId);
+  const [record, , onChange] = useForm<Partial<Funder>>({});
+  const [emailError, setEmailError] = useState('');
+  const snackbar = useSnackbar();
+
+  const onCancel = useCallback(() => {
+    if (fundingEntityId) {
+      goToFundingEntity(fundingEntityId);
+    }
+  }, [fundingEntityId]);
+
+  const onSendClick = async () => {
+    if (!fundingEntityId) {
+      snackbar.toastError();
+      return;
+    }
+
+    if (!record.email || record.email === '') {
+      setEmailError(strings.REQUIRED_FIELD);
+      return;
+    }
+
+    if (!EMAIL_REGEX.test(record.email)) {
+      setEmailError(strings.INCORRECT_EMAIL_FORMAT);
+      return;
+    }
+
+    let successMessage: string | null = null;
+    const response = await FundingEntityService.inviteFunder(fundingEntityId, record.email);
path: src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
user: login: ice1080
body: I thought redux was only used when we're doing something with the data, since redux is a state store, and we're not using the data in this response? 
I'm happy to move it to redux though if we prefer to use it even when we're not going to put anything in the store
created_at: 2025-03-28T02:31:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,143 @@
+import React, { useCallback, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Container, Grid, Typography, useTheme } from '@mui/material';
+import { Textfield } from '@terraware/web-components';
+
+import Page from 'src/components/Page';
+import Card from 'src/components/common/Card';
+import PageForm from 'src/components/common/PageForm';
+import { EMAIL_REGEX } from 'src/constants';
+import useNavigateTo from 'src/hooks/useNavigateTo';
+import { useFundingEntity } from 'src/providers';
+import FundingEntityService from 'src/services/FundingEntityService';
+import strings from 'src/strings';
+import { Funder } from 'src/types/FundingEntity';
+import useDeviceInfo from 'src/utils/useDeviceInfo';
+import useForm from 'src/utils/useForm';
+import useSnackbar from 'src/utils/useSnackbar';
+
+const InviteView = () => {
+  const { goToFundingEntity } = useNavigateTo();
+  const { fundingEntity, reload: reloadFundingEntity } = useFundingEntity();
+  const theme = useTheme();
+  const { isMobile } = useDeviceInfo();
+  const pathParams = useParams<{ fundingEntityId: string }>();
+  const fundingEntityId = Number(pathParams.fundingEntityId);
+  const [record, , onChange] = useForm<Partial<Funder>>({});
+  const [emailError, setEmailError] = useState('');
+  const snackbar = useSnackbar();
+
+  const onCancel = useCallback(() => {
+    if (fundingEntityId) {
+      goToFundingEntity(fundingEntityId);
+    }
+  }, [fundingEntityId]);
+
+  const onSendClick = async () => {
+    if (!fundingEntityId) {
+      snackbar.toastError();
+      return;
+    }
+
+    if (!record.email || record.email === '') {
+      setEmailError(strings.REQUIRED_FIELD);
+      return;
+    }
+
+    if (!EMAIL_REGEX.test(record.email)) {
+      setEmailError(strings.INCORRECT_EMAIL_FORMAT);
+      return;
+    }
+
+    let successMessage: string | null = null;
+    const response = await FundingEntityService.inviteFunder(fundingEntityId, record.email);
path: src/scenes/AcceleratorRouter/FundingEntities/InviteView.tsx
user: login: ice1080
body: Updated to redux, I wasn't thinking about the fact that it's unrelated to state management and more related to just not wanting api calls in the components.
created_at: 2025-03-28T16:21:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-27T21:45:08Z
body: [vc]: #qwEL62sd5xyQssEkUX+UcO1oNGo1/VZWlKcEwAz6+Yk=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0cmVlLWxvY2F0aW9uLXdlYiIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90ZXJyYWZvcm1hdGlvbi90cmVlLWxvY2F0aW9uLXdlYi9lcjM3TW4yaDFyMmtlVjJWQ3BRbTEyemU4Q1d0IiwicHJldmlld1VybCI6InRyZWUtbG9jYXRpb24td2ViLWdpdC1pc2FhYy1pbnZpdGUtZnVuZGVyLXRlcnJhZm9ybWF0aW9uLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidHJlZS1sb2NhdGlvbi13ZWItZ2l0LWlzYWFjLWludml0ZS1mdW5kZXItdGVycmFmb3JtYXRpb24udmVyY2VsLmFwcCJ9fSx7Im5hbWUiOiJ0ZXJyYXdhcmUtd2ViLXN0b3J5Ym9vayIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90ZXJyYWZvcm1hdGlvbi90ZXJyYXdhcmUtd2ViLXN0b3J5Ym9vay9GSGNaeThYRTRNcFJOZ3Nnc2lnUXl6aVdnUHF0IiwicHJldmlld1VybCI6InRlcnJhd2FyZS13ZWItc3Rvcnlib29rLWdpdC1pc2FhYy1pbnZpdGUtZnVuZGVyLXRlcnJhZm9ybWF0aW9uLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidGVycmF3YXJlLXdlYi1zdG9yeWJvb2stZ2l0LWlzYWFjLWludml0ZS1mdW5kZXItdGVycmFmb3JtYXRpb24udmVyY2VsLmFwcCJ9fV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **terraware-web-storybook** | ✅ Ready ([Inspect](https://vercel.com/terraformation/terraware-web-storybook/FHcZy8XE4MpRNgsgsigQyziWgPqt)) | [Visit Preview](https://terraware-web-storybook-git-isaac-invite-funder-terraformation.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/terraware-web-storybook-git-isaac-invite-funder-terraformation.vercel.app?via=pr-comment-feedback-link) | Mar 28, 2025 5:47pm |
| **tree-location-web** | ✅ Ready ([Inspect](https://vercel.com/terraformation/tree-location-web/er37Mn2h1r2keV2VCpQm12ze8CWt)) | [Visit Preview](https://tree-location-web-git-isaac-invite-funder-terraformation.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/tree-location-web-git-isaac-invite-funder-terraformation.vercel.app?via=pr-comment-feedback-link) | Mar 28, 2025 5:47pm |



author_association: NONE
user: login: ice1080
created_at: 2025-03-27T21:45:22Z
body: * **#3756** <a href="https://app.graphite.dev/github/pr/terraware/terraware-web/3756?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/terraware/terraware-web/3756?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR