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} +

    + + +
    +); + +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 = () => ( +
    + + Back to top + +
    +); + +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:
    + + +
    + ); +}; + +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) => ( + + ))} +
    +
    + ); +}; + +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 ( + + + +
    + + + {title} + + +
    + {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;