diff --git a/package-lock.json b/package-lock.json
index 4137a21aa..b6a8e78c0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
"cnbuilder": "^3.1.0",
"cookie-parser": "^1.4.6",
"decanter": "^7.0.0-rc.2",
+ "fast-sort": "^3.4.0",
"gatsby": "^4.14.0",
"gatsby-link": "^4.14.0",
"gatsby-plugin-fontawesome-css": "^1.2.0",
@@ -10006,6 +10007,11 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
+ "node_modules/fast-sort": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/fast-sort/-/fast-sort-3.4.0.tgz",
+ "integrity": "sha512-c/cMBGA5mH3OYjaXedtLIM3hQjv+KuZuiD2QEH5GofNOZeQVDIYIN7Okc2AW1KPhk44g5PTZnXp8t2lOMl8qhQ=="
+ },
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@@ -55299,6 +55305,11 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
+ "fast-sort": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/fast-sort/-/fast-sort-3.4.0.tgz",
+ "integrity": "sha512-c/cMBGA5mH3OYjaXedtLIM3hQjv+KuZuiD2QEH5GofNOZeQVDIYIN7Okc2AW1KPhk44g5PTZnXp8t2lOMl8qhQ=="
+ },
"fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
diff --git a/package.json b/package.json
index 5c852c496..941336f29 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"cnbuilder": "^3.1.0",
"cookie-parser": "^1.4.6",
"decanter": "^7.0.0-rc.2",
+ "fast-sort": "^3.4.0",
"gatsby": "^4.14.0",
"gatsby-link": "^4.14.0",
"gatsby-plugin-fontawesome-css": "^1.2.0",
diff --git a/src/api/contentful/associates.js b/src/api/contentful/associates.js
new file mode 100644
index 000000000..ff0f0b993
--- /dev/null
+++ b/src/api/contentful/associates.js
@@ -0,0 +1,12 @@
+import fetchNames from '../../utilities/contentful/associates';
+
+export default async function handler(req, res) {
+ try {
+ const names = await fetchNames();
+ res.status(200).json(names);
+ } catch (error) {
+ res.status(200).json([]);
+ // eslint-disable-next-line no-console
+ console.log(error.config);
+ }
+}
diff --git a/src/components/components.js b/src/components/components.js
index 185ca59dd..8c8b38265 100644
--- a/src/components/components.js
+++ b/src/components/components.js
@@ -84,6 +84,8 @@ import VerticalNavWrapper from './navigation/verticalNavWrapper';
import VerticalNavItem from './navigation/verticalNavItem';
import Wysiwyg from './simple/wysiwyg';
import MembershipPaymentOptions from './page-types/membershipFormPage/membershipPaymentOptions';
+import AssociatesDirectoryPage from './page-types/associatesDirectoryPage/associatesDirectoryPage';
+import AssociatesDirectory from './page-types/associatesDirectoryPage/associatesDirectory';
import MegaMenu from './navigation/MegaMenu/megaMenu';
import MegaMenuPanel from './navigation/MegaMenu/megaMenuPanel';
import MegaMenuCard from './navigation/MegaMenu/megaMenuCard';
@@ -95,6 +97,8 @@ const ComponentList = {
accordionItem: AccordionItem,
alert: SBAlert,
alertCtaLink: SBAlertCtaLink,
+ associatesDirectoryPage: AssociatesDirectoryPage,
+ associatesDirectory: AssociatesDirectory,
basicCard: BasicCard,
basicCardHorizontal: BasicCardHorizontal,
basicPage: BasicPage,
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/Associate.jsx b/src/components/page-types/associatesDirectoryPage/Directory/Associate.jsx
new file mode 100644
index 000000000..61a0fc161
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/Associate.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const AssociateProps = {
+ isEnabled: PropTypes.bool,
+ person: PropTypes.shape({
+ name: PropTypes.shape({
+ first: PropTypes.string,
+ last: PropTypes.string,
+ }),
+ yearAdded: PropTypes.number,
+ }),
+};
+
+const Associate = ({ isEnabled = true, person }) => {
+ if (!isEnabled) return null;
+
+ return (
+
+
+ {person.name.first} {person.name.last}
+
+
+
+ {Object.values(person.years || []).join(', ')}
+
+
+ );
+};
+
+Associate.propTypes = AssociateProps;
+
+export default Associate;
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/AssociateList.jsx b/src/components/page-types/associatesDirectoryPage/Directory/AssociateList.jsx
new file mode 100644
index 000000000..54127ed99
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/AssociateList.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Associate from './Associate';
+import BackToTopLink from './BackToTopLink';
+
+const AssociateListProps = {
+ letter: PropTypes.string,
+ associates: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.shape({
+ first: PropTypes.string,
+ last: PropTypes.string,
+ }),
+ yearAdded: PropTypes.number,
+ })
+ ),
+ onlyNewMembers: PropTypes.bool,
+ recentYear: PropTypes.number,
+};
+
+const AssociateList = ({ letter, associates, onlyNewMembers, recentYear }) => (
+
+
+ {letter}
+
+
+ {associates?.map((person, index) => (
+
+ ))}
+
+
+
+);
+
+AssociateList.propTypes = AssociateListProps;
+
+export default AssociateList;
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/BackToTopLink.jsx b/src/components/page-types/associatesDirectoryPage/Directory/BackToTopLink.jsx
new file mode 100644
index 000000000..9a7fd2fc1
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/BackToTopLink.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const BackToTopLink = () => (
+
+);
+
+export default BackToTopLink;
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/Directory.jsx b/src/components/page-types/associatesDirectoryPage/Directory/Directory.jsx
new file mode 100644
index 000000000..2c0414f3e
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/Directory.jsx
@@ -0,0 +1,98 @@
+import React, { useState, useEffect } from 'react';
+import axios from 'axios';
+import Tabs from './Tabs';
+import Results from './Results';
+
+const Directory = () => {
+ const [associatesData, setAssociatesData] = useState({});
+ const [onlyNewMembers, setOnlyNewMembers] = useState(false);
+ const [recentYear, setRecentYear] = useState(null);
+ const [search, setSearch] = useState('');
+ const [filteredList, setFilteredList] = useState([]);
+
+ const filterResult = () => {
+ const result = associatesData.list?.filter((person) => {
+ const isVisible =
+ person.fullNameWithYears.includes(search.toLowerCase()) &&
+ (!onlyNewMembers || person.yearAdded === recentYear);
+ return isVisible;
+ });
+ setFilteredList(result);
+ };
+
+ const handleNewMembersToggle = (event) => {
+ setOnlyNewMembers(event.target.checked);
+ };
+
+ const handleSearch = (event) => {
+ setSearch(event.target.value);
+ };
+
+ useEffect(() => {
+ const fetchData = async () => {
+ axios
+ .get('/api/contentful/associates')
+ .then((response) => {
+ setAssociatesData(response.data);
+
+ const mostRecent = Math.max(
+ ...response.data.list.map((person) => person.yearAdded || 0)
+ );
+ setRecentYear(mostRecent);
+ })
+ .catch((error) => {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ });
+ };
+
+ fetchData();
+ }, []);
+
+ useEffect(() => {
+ filterResult();
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [onlyNewMembers, search]);
+
+ return (
+
+
+
{associatesData.total} Associates Total
+
+
+
+
+
+
+
+ {search.length ?
: ''}
+
+ {!search.length && (
+
+ )}
+
+ );
+};
+
+export default Directory;
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/Results.jsx b/src/components/page-types/associatesDirectoryPage/Directory/Results.jsx
new file mode 100644
index 000000000..fadab89bf
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/Results.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Associate from './Associate';
+import BackToTopLink from './BackToTopLink';
+
+const ResultsProps = {
+ onlyNewMembers: PropTypes.bool,
+ recentYear: PropTypes.number,
+};
+
+const Results = ({ filteredList }) => {
+ const total = filteredList?.length;
+ if (!total) {
+ return No associates found.
;
+ }
+ return (
+
+
{total} associates found:
+
+ {filteredList?.map((person, index) => (
+
+ ))}
+
+
+
+ );
+};
+
+Results.propTypes = ResultsProps;
+
+export default Results;
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/TabHeader.jsx b/src/components/page-types/associatesDirectoryPage/Directory/TabHeader.jsx
new file mode 100644
index 000000000..0dd54a505
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/TabHeader.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const TabHeaderProps = {
+ group: PropTypes.string,
+ activeTab: PropTypes.string,
+ handleTabClick: PropTypes.func,
+ handleKeyPress: PropTypes.func,
+};
+
+const TabHeader = ({ group, activeTab, handleTabClick, handleKeyPress }) => {
+ const isActive = activeTab === group;
+
+ return (
+
+ {group}
+
+ );
+};
+TabHeader.propTypes = TabHeaderProps;
+
+export default TabHeader;
diff --git a/src/components/page-types/associatesDirectoryPage/Directory/Tabs.jsx b/src/components/page-types/associatesDirectoryPage/Directory/Tabs.jsx
new file mode 100644
index 000000000..55f097dd7
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/Directory/Tabs.jsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import AssociateList from './AssociateList';
+import TabHeader from './TabHeader';
+
+const TabsProps = {
+ onlyNewMembers: PropTypes.bool,
+ recentYear: PropTypes.number,
+};
+
+const tabsGroups = {
+ 'A-B': ['A', 'B'],
+ 'C-D': ['C', 'D'],
+ 'E-F': ['E', 'F'],
+ 'G-H': ['G', 'H'],
+ 'I-J': ['I', 'J'],
+ 'K-L': ['K', 'L'],
+ 'M-N': ['M', 'N'],
+ 'O-P': ['O', 'P'],
+ 'Q-R': ['Q', 'R'],
+ 'S-T': ['S', 'T'],
+ 'U-V': ['U', 'V'],
+ 'W-X': ['W', 'X'],
+ 'Y-Z': ['Y', 'Z'],
+};
+const Tabs = ({ groupedNames, onlyNewMembers, recentYear }) => {
+ const [activeTab, setActiveTab] = useState('A-B');
+
+ const handleTabClick = (event) => {
+ setActiveTab(event.target.dataset.group);
+ };
+
+ const handleKeyPress = (event) => {
+ if (event.key === 'ArrowLeft') {
+ const currentIndex = Object.keys(tabsGroups).indexOf(activeTab);
+ if (currentIndex === 0) return;
+
+ const nextIndex = currentIndex - 1;
+ const nextTab = Object.keys(tabsGroups)[nextIndex];
+ setActiveTab(nextTab);
+ }
+ if (event.key === 'ArrowRight') {
+ const currentIndex = Object.keys(tabsGroups).indexOf(activeTab);
+ if (currentIndex === Object.keys(tabsGroups).length - 1) return;
+
+ const nextIndex = currentIndex + 1;
+ const nextTab = Object.keys(tabsGroups)[nextIndex];
+ setActiveTab(nextTab);
+ }
+ };
+
+ return (
+
+
+
+ {Object.keys(tabsGroups).map((group) => (
+
+ {tabsGroups[group].map((letter) => (
+
+ ))}
+
+ ))}
+
+
+ );
+};
+
+Tabs.propTypes = TabsProps;
+
+export default Tabs;
diff --git a/src/components/page-types/associatesDirectoryPage/associatesDirectory.js b/src/components/page-types/associatesDirectoryPage/associatesDirectory.js
new file mode 100644
index 000000000..adef61e09
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/associatesDirectory.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import SbEditable from 'storyblok-react';
+import { Container } from '../../layout/Container';
+import Directory from './Directory/Directory';
+
+const AssociatesDirectory = (props) => {
+ const { blok } = props;
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default AssociatesDirectory;
diff --git a/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js b/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js
new file mode 100644
index 000000000..b244aff15
--- /dev/null
+++ b/src/components/page-types/associatesDirectoryPage/associatesDirectoryPage.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import SbEditable from 'storyblok-react';
+import { dcnb } from 'cnbuilder';
+import { Container } from '../../layout/Container';
+import { Heading } from '../../simple/Heading';
+import Layout from '../../partials/layout';
+import hasRichText from '../../../utilities/hasRichText';
+import RichTextRenderer from '../../../utilities/richTextRenderer';
+import CreateBloks from '../../../utilities/createBloks';
+
+const AssociatesDirectoryPage = (props) => {
+ const {
+ blok: { title, intro = {}, directory },
+ blok,
+ } = props;
+
+ return (
+
+
+
+
+ {hasRichText(intro) && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default AssociatesDirectoryPage;
diff --git a/src/utilities/contentful/api.js b/src/utilities/contentful/api.js
new file mode 100644
index 000000000..30a8a4d47
--- /dev/null
+++ b/src/utilities/contentful/api.js
@@ -0,0 +1,24 @@
+import axios from 'axios';
+import keys from './keys';
+
+class ContentFulAPI {
+ constructor(space = keys.space, accessToken = keys.accessToken) {
+ this.host = 'https://cdn.contentful.com';
+ this.space = space;
+ this.accessToken = accessToken;
+ }
+
+ async fetchEntries(limit, skip) {
+ const apiUrl = `${this.host}/spaces/${this.space}/entries?access_token=${this.accessToken}&limit=${limit}&skip=${skip}&order=sys.id`;
+
+ const response = await axios.get(apiUrl);
+ const { items, total } = response.data;
+
+ return {
+ items: items.map((item) => item.fields),
+ total,
+ };
+ }
+}
+
+export default ContentFulAPI;
diff --git a/src/utilities/contentful/associates/index.js b/src/utilities/contentful/associates/index.js
new file mode 100644
index 000000000..2856b9817
--- /dev/null
+++ b/src/utilities/contentful/associates/index.js
@@ -0,0 +1,68 @@
+import { sort } from 'fast-sort';
+
+import ContentFulAPI from '../api';
+import keys from '../keys';
+
+const addFullNameWithYears = (associate) => {
+ const { name } = associate;
+ const { first, last } = name;
+ const years = (associate.years ? Object.values(associate.years) : []).join(
+ ' '
+ );
+ const fullNameWithYears = `${first} ${last} (${years})`.toLocaleLowerCase();
+ return {
+ ...associate,
+ fullNameWithYears,
+ };
+};
+
+const fetchNames = async (
+ space = keys.space,
+ accessToken = keys.accessToken
+) => {
+ const client = new ContentFulAPI(space, accessToken);
+ const { items, total } = await client.fetchEntries(1000, 0);
+ let associates = items.map((associate) => addFullNameWithYears(associate));
+
+ const loops = Math.ceil(total / 1000);
+ let skip = 0;
+ const requests = [];
+
+ for (let i = 0; i < loops; i += 1) {
+ skip += 1000;
+ requests.push(client.fetchEntries(1000, skip));
+ }
+
+ const responses = await Promise.all(requests);
+ const newAssociates = responses.reduce(
+ (acc, response) =>
+ acc.concat(
+ response.items.map((associate) => addFullNameWithYears(associate))
+ ),
+ []
+ );
+
+ associates = associates.concat(newAssociates);
+ const sortedNames = sort(associates).asc([
+ (person) => person.name.last,
+ (person) => person.name.first,
+ (person) => person.name.middle,
+ ]);
+
+ const grouped = sortedNames.reduce((acc, person) => {
+ const firstLetter = person.name.last[0].toUpperCase();
+ if (!acc[firstLetter]) {
+ acc[firstLetter] = [];
+ }
+ acc[firstLetter].push(person);
+ return acc;
+ }, {});
+
+ return {
+ list: sortedNames,
+ grouped,
+ total: sortedNames.length,
+ };
+};
+
+export default fetchNames;
diff --git a/src/utilities/contentful/keys.js b/src/utilities/contentful/keys.js
new file mode 100644
index 000000000..1b3b2ecc2
--- /dev/null
+++ b/src/utilities/contentful/keys.js
@@ -0,0 +1,6 @@
+const keys = {
+ space: process.env.ASSOCIATES_CONTENTFUL_SPACE,
+ accessToken: process.env.ASSOCIATES_CONTENTFUL_TOKEN,
+};
+
+export default keys;