diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js
index b24df18cd1..9e0a05ee14 100644
--- a/services/web/app/src/Features/Project/ProjectListController.js
+++ b/services/web/app/src/Features/Project/ProjectListController.js
@@ -293,6 +293,7 @@ async function projectListReactPage(req, res, next) {
notifications,
notificationsInstitution,
user,
+ userAffiliations,
userEmails,
reconfirmedViaSAML,
allInReconfirmNotificationPeriods,
diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug
index b52e2839fc..cac3ebd9ac 100644
--- a/services/web/app/views/project/list-react.pug
+++ b/services/web/app/views/project/list-react.pug
@@ -13,6 +13,7 @@ block append meta
meta(name="ol-userEmails" data-type="json" content=userEmails)
meta(name="ol-allInReconfirmNotificationPeriods" data-type="json" content=allInReconfirmNotificationPeriods)
meta(name="ol-user" data-type="json" content=user)
+ meta(name="ol-userAffiliations" data-type="json" content=userAffiliations)
meta(name="ol-reconfirmedViaSAML" content=reconfirmedViaSAML)
meta(name="ol-survey" data-type="json" content=survey)
meta(name="ol-tags" data-type="json" content=tags)
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 3cc1ba1ffe..300eb19157 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -30,6 +30,7 @@
"archived": "",
"archived_projects": "",
"archiving_projects_wont_affect_collaborators": "",
+ "are_you_affiliated_with_an_institution": "",
"are_you_still_at": "",
"ascending": "",
"ask_proj_owner_to_upgrade_for_git_bridge": "",
diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
index 1bac43455c..1205aecd7c 100644
--- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx
+++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
@@ -10,6 +10,7 @@ import CurrentPlanWidget from './current-plan-widget/current-plan-widget'
import NewProjectButton from './new-project-button'
import ProjectListTable from './table/project-list-table'
import SidebarFilters from './sidebar/sidebar-filters'
+import AddAffiliation, { useAddAffiliation } from './sidebar/add-affiliation'
import SurveyWidget from './survey-widget'
import WelcomeMessage from './welcome-message'
import LoadingBranded from '../../../shared/components/loading-branded'
@@ -42,6 +43,7 @@ function ProjectListPageContent() {
setSearchText,
selectedProjects,
} = useProjectListContext()
+ const { show: showAddAffiliationWidget } = useAddAffiliation()
useEffect(() => {
eventTracking.sendMB('loads_v2_dash', {})
@@ -62,6 +64,8 @@ function ProjectListPageContent() {
diff --git a/services/web/frontend/js/features/project-list/components/sidebar/add-affiliation.tsx b/services/web/frontend/js/features/project-list/components/sidebar/add-affiliation.tsx
new file mode 100644
index 0000000000..057aae94b2
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/sidebar/add-affiliation.tsx
@@ -0,0 +1,34 @@
+import { useTranslation } from 'react-i18next'
+import { Button } from 'react-bootstrap'
+import { useProjectListContext } from '../../context/project-list-context'
+import getMeta from '../../../../utils/meta'
+import { Affiliation } from '../../../../../../types/affiliation'
+import { ExposedSettings } from '../../../../../../types/exposed-settings'
+
+export function useAddAffiliation() {
+ const { totalProjectsCount } = useProjectListContext()
+ const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings
+ const userAffiliations = getMeta('ol-userAffiliations', []) as Affiliation[]
+
+ return { show: isOverleaf && totalProjectsCount && !userAffiliations.length }
+}
+
+function AddAffiliation() {
+ const { t } = useTranslation()
+ const { show } = useAddAffiliation()
+
+ if (!show) {
+ return null
+ }
+
+ return (
+
+
{t('are_you_affiliated_with_an_institution')}
+
+
+ )
+}
+
+export default AddAffiliation
diff --git a/services/web/frontend/stories/project-list/add-affiliation.stories.tsx b/services/web/frontend/stories/project-list/add-affiliation.stories.tsx
new file mode 100644
index 0000000000..51a1a16f43
--- /dev/null
+++ b/services/web/frontend/stories/project-list/add-affiliation.stories.tsx
@@ -0,0 +1,29 @@
+import AddAffiliation from '../../js/features/project-list/components/sidebar/add-affiliation'
+import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
+import useFetchMock from '../hooks/use-fetch-mock'
+import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
+
+export const Add = (args: any) => {
+ window.metaAttributesCache = new Map()
+ window.metaAttributesCache.set('ol-ExposedSettings', {
+ isOverleaf: true,
+ })
+ window.metaAttributesCache.set('ol-userAffiliations', [])
+ useFetchMock(fetchMock => {
+ fetchMock.post(/\/api\/project/, {
+ projects: projectsData,
+ totalSize: projectsData.length,
+ })
+ })
+
+ return (
+
+
+
+ )
+}
+
+export default {
+ title: 'Project List / Affiliation',
+ component: AddAffiliation,
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 4dd3232035..d9f0deb54f 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1584,6 +1584,7 @@
"link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
"looks_like_youre_at": "It looks like you’re at <0>__institutionName__0>!",
"add_affiliation": "Add Affiliation",
+ "are_you_affiliated_with_an_institution": "Are you affiliated with an institution?",
"did_you_know_institution_providing_professional": "Did you know that __institutionName__ is providing <0>free __appName__ Professional features0> to everyone at __institutionName__?",
"add_email_to_claim_features": "Add an institutional email address to claim your features.",
"please_change_primary_to_remove": "Please change your primary email in order to remove",
diff --git a/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx
new file mode 100644
index 0000000000..d0df0f0ae2
--- /dev/null
+++ b/services/web/test/frontend/features/project-list/components/sidebar/add-affiliation.test.tsx
@@ -0,0 +1,76 @@
+import { screen, waitFor } from '@testing-library/react'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import { renderWithProjectListContext } from '../../helpers/render-with-context'
+import AddAffiliation from '../../../../../../frontend/js/features/project-list/components/sidebar/add-affiliation'
+import { Affiliation } from '../../../../../../types/affiliation'
+
+describe('Add affiliation widget', function () {
+ const validateNonExistence = () => {
+ expect(screen.queryByText(/are you affiliated with an institution/i)).to.be
+ .null
+ expect(screen.queryByRole('link', { name: /add affiliation/i })).to.be.null
+ }
+
+ beforeEach(function () {
+ window.metaAttributesCache = new Map()
+ fetchMock.reset()
+ })
+
+ afterEach(function () {
+ window.metaAttributesCache = new Map()
+ fetchMock.reset()
+ })
+
+ it('renders the component', async function () {
+ window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
+ window.metaAttributesCache.set('ol-userAffiliations', [])
+
+ renderWithProjectListContext()
+
+ await fetchMock.flush(true)
+ await waitFor(() => expect(fetchMock.called('/api/project')))
+
+ screen.getByText(/are you affiliated with an institution/i)
+ const addAffiliationLink = screen.getByRole('link', {
+ name: /add affiliation/i,
+ })
+ expect(addAffiliationLink.getAttribute('href')).to.equal('/user/settings')
+ })
+
+ it('does not render when `isOverleaf` is `false`', async function () {
+ window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: false })
+ window.metaAttributesCache.set('ol-userAffiliations', [])
+
+ renderWithProjectListContext()
+
+ await fetchMock.flush(true)
+ await waitFor(() => expect(fetchMock.called('/api/project')))
+
+ validateNonExistence()
+ })
+
+ it('does not render when there no projects', async function () {
+ window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
+ window.metaAttributesCache.set('ol-userAffiliations', [])
+
+ renderWithProjectListContext(, { projects: [] })
+
+ await fetchMock.flush(true)
+ await waitFor(() => expect(fetchMock.called('/api/project')))
+
+ validateNonExistence()
+ })
+
+ it('does not render when there are affiliations', async function () {
+ window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
+ window.metaAttributesCache.set('ol-userAffiliations', [{} as Affiliation])
+
+ renderWithProjectListContext()
+
+ await fetchMock.flush(true)
+ await waitFor(() => expect(fetchMock.called('/api/project')))
+
+ validateNonExistence()
+ })
+})