diff --git a/package-lock.json b/package-lock.json index 4afdbb6f..18367f7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,10 +60,12 @@ "zustand": "^4.3.1" }, "devDependencies": { - "@testing-library/jest-dom": "^5.16.5", + "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.1", "@types/d3-force": "^3.0.4", "@types/papaparse": "^5.3.8", + "@types/testing-library__user-event": "^4.2.0", "autoprefixer": "^10.4.13", "babel-jest": "^29.5.0", "identity-obj-proxy": "^3.0.0", @@ -2866,9 +2868,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", - "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", "dev": true, "dependencies": { "@adobe/css-tools": "^4.0.1", @@ -2972,6 +2974,19 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", + "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -3223,6 +3238,16 @@ "@types/jest": "*" } }, + "node_modules/@types/testing-library__user-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/testing-library__user-event/-/testing-library__user-event-4.2.0.tgz", + "integrity": "sha512-vHuDMJY+UooghUtgFX+OucrhQWLLNUwgSOyvVkHNr+5gYag3a7xVkWNF0hyZID/+qHNw87wFqM/5uagFZ5eQIg==", + "deprecated": "This is a stub types definition. testing-library__user-event provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "@testing-library/user-event": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -14171,9 +14196,9 @@ } }, "@testing-library/jest-dom": { - "version": "5.16.5", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", - "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "version": "5.17.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", + "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", "dev": true, "requires": { "@adobe/css-tools": "^4.0.1", @@ -14249,6 +14274,13 @@ "@types/react-dom": "^18.0.0" } }, + "@testing-library/user-event": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", + "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", + "dev": true, + "requires": {} + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -14490,6 +14522,15 @@ "@types/jest": "*" } }, + "@types/testing-library__user-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/testing-library__user-event/-/testing-library__user-event-4.2.0.tgz", + "integrity": "sha512-vHuDMJY+UooghUtgFX+OucrhQWLLNUwgSOyvVkHNr+5gYag3a7xVkWNF0hyZID/+qHNw87wFqM/5uagFZ5eQIg==", + "dev": true, + "requires": { + "@testing-library/user-event": "*" + } + }, "@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", diff --git a/package.json b/package.json index 28b6dfe6..85d1b24e 100644 --- a/package.json +++ b/package.json @@ -64,10 +64,12 @@ "zustand": "^4.3.1" }, "devDependencies": { - "@testing-library/jest-dom": "^5.16.5", + "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.1", "@types/d3-force": "^3.0.4", "@types/papaparse": "^5.3.8", + "@types/testing-library__user-event": "^4.2.0", "autoprefixer": "^10.4.13", "babel-jest": "^29.5.0", "identity-obj-proxy": "^3.0.0", diff --git a/src/axiosInstance.ts b/src/axiosInstance.ts index 67fea65b..7554acbe 100644 --- a/src/axiosInstance.ts +++ b/src/axiosInstance.ts @@ -1,11 +1,10 @@ import axios, { AxiosError, AxiosResponse } from 'axios'; import { conf } from './configs/index'; import { StorageService } from './services/StorageService'; -import router from 'next/router'; import * as Sentry from '@sentry/nextjs'; import { toast } from 'react-toastify'; -import { IUser } from './utils/types'; +import { IToken } from './utils/types'; import { tokenRefreshEventEmitter } from './services/EventEmitter'; let isRefreshing = false; @@ -16,14 +15,14 @@ export const axiosInstance = axios.create({ axiosInstance.interceptors.request.use( async (config: any) => { - const user: IUser | undefined = - StorageService.readLocalStorage('user'); + const user: IToken | undefined = + StorageService.readLocalStorage('user'); if (user) { - const { token } = user; + const { accessToken } = user; - if (token.accessToken) { - config.headers!['Authorization'] = `Bearer ${token.accessToken}`; + if (accessToken) { + config.headers!['Authorization'] = `Bearer ${accessToken}`; } } @@ -52,8 +51,8 @@ axiosInstance.interceptors.response.use( }); break; case 401: - const user: IUser | undefined = - StorageService.readLocalStorage('user'); + const user: IToken | undefined = + StorageService.readLocalStorage('user'); if ( error.response?.status === 401 && @@ -78,25 +77,22 @@ axiosInstance.interceptors.response.use( !error.config.url?.endsWith('/auth/refresh-tokens') && user ) { - const { token } = user; + const { accessToken, refreshToken } = user; - if (token.refreshToken && !isRefreshing) { + if (refreshToken && !isRefreshing) { isRefreshing = true; try { const response = await axiosInstance.post( '/auth/refresh-tokens', { - refreshToken: token.refreshToken, + refreshToken: refreshToken, } ); StorageService.writeLocalStorage('user', { - guild: user.guild, - token: { - accessToken: response.data.access.token, - refreshToken: response.data.refresh.token, - }, + accessToken: response.data.access.token, + refreshToken: response.data.refresh.token, }); axiosInstance.defaults.headers['Authorization'] = @@ -118,7 +114,7 @@ axiosInstance.interceptors.response.use( } finally { isRefreshing = false; } - } else if (token.refreshToken && isRefreshing) { + } else if (refreshToken && isRefreshing) { // If a refresh is already in progress, listen for the completion event return new Promise((resolve, reject) => { tokenRefreshEventEmitter.subscribe('tokenRefresh', (newToken) => { diff --git a/src/components/centric/selectCommunity/TcCommunityList.tsx b/src/components/centric/selectCommunity/TcCommunityList.tsx new file mode 100644 index 00000000..f55deab0 --- /dev/null +++ b/src/components/centric/selectCommunity/TcCommunityList.tsx @@ -0,0 +1,24 @@ +import React, { useEffect, useState } from 'react'; +import TcCommunityListItems from './TcCommunityListItems'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; + +function TcCommunityList({ fetchedCommunities, handleActiveCommunity }: any) { + const [activeCommunity, setActiveCommunity] = + useState(); + const handleSelectedCommunity = (community: IDiscordModifiedCommunity) => { + setActiveCommunity(community); + }; + + useEffect(() => { + handleActiveCommunity(activeCommunity); + }, [activeCommunity]); + + return ( + + ); +} + +export default TcCommunityList; diff --git a/src/components/centric/selectCommunity/TcCommunityListItems.tsx b/src/components/centric/selectCommunity/TcCommunityListItems.tsx new file mode 100644 index 00000000..eba8f610 --- /dev/null +++ b/src/components/centric/selectCommunity/TcCommunityListItems.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react'; +import TcAvatar from '../../shared/TcAvatar'; +import TcText from '../../shared/TcText'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; +import clsx from 'clsx'; +import { StorageService } from '../../../services/StorageService'; +import { MdGroups } from 'react-icons/md'; +import { conf } from '../../../configs'; +import Image from 'next/image'; + +/** + * Props for the TcCommunityListItems component. + */ +interface ITcCommunityListItemsProps { + /** + * Array of community objects with avatar URLs and labels. + */ + communities: IDiscordModifiedCommunity[]; + onSelectCommunity: (selectedCommunity: IDiscordModifiedCommunity) => void; +} + +/** + * TcCommunityListItems Component + * + * Renders a list of community items, each displaying an avatar and a label. + * Features include: + * - Reading the currently selected community from local storage on initial render. + * - Updating the selected community both internally and via `onSelectCommunity` callback when a community is clicked. + * - Responsive layout for different screen sizes. + * - Displaying a message when there are no communities. + * + * Props: + * - communities (IDiscordModifiedCommunity[]): Array of community objects with `avatarURL` and `name`. + * - onSelectCommunity (Function): Callback when a community is selected. + * + * Usage: + * + */ + +function TcCommunityListItems({ + communities, + onSelectCommunity, +}: ITcCommunityListItemsProps) { + const [selectedCommunity, setSelectedCommunity] = + useState(); + + useEffect(() => { + const community = + StorageService.readLocalStorage('community'); + setSelectedCommunity(community); + }, []); + + useEffect(() => { + if (selectedCommunity) { + onSelectCommunity(selectedCommunity); + } + }, [selectedCommunity]); + + const renderPlatformAvatar = (community: IDiscordModifiedCommunity) => { + let activeCommunityPlatformIcon; + + if (community?.platforms) { + activeCommunityPlatformIcon = community.platforms.find( + (platform) => platform.disconnectedAt === null + ); + } + + if ( + activeCommunityPlatformIcon && + activeCommunityPlatformIcon.metadata && + activeCommunityPlatformIcon.metadata.icon + ) { + return ( + {activeCommunityPlatformIcon.metadata.name + ); + } + + return ; + }; + + if (communities.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {communities.map((community, index) => ( +
setSelectedCommunity(community)} + > + {community?.avatarURL ? ( + + ) : ( + + {renderPlatformAvatar(community)} + + )} + +
+ ))} +
+ ); +} + +export default TcCommunityListItems; diff --git a/src/components/centric/selectCommunity/TcSelectCommunity.spec.tsx b/src/components/centric/selectCommunity/TcSelectCommunity.spec.tsx new file mode 100644 index 00000000..32b0e73d --- /dev/null +++ b/src/components/centric/selectCommunity/TcSelectCommunity.spec.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TcCommunityListItems from './TcCommunityListItems'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; + +describe('TcCommunityListItems', () => { + const mockCommunities: IDiscordModifiedCommunity[] = [ + { + id: '1', + name: 'Community 1', + platforms: [ + { + name: 'discord', + metadata: { + id: '1012430565959553145', + icon: '889a294bb7237dc6d4206caa93e79faf', + name: "nimatorabiv's server", + selectedChannels: [ + '1118087567372455966', + '1012430565959553148', + '1124246469516460114', + '1012430565959553149', + '1018893637326749858', + '1155092883565723698', + '1155094012768825375', + ], + period: '2022-12-01T00:00:00.000Z', + analyzerStartedAt: '2023-12-01T11:29:44.013Z', + }, + disconnectedAt: null, + id: '6569c3a12k491e542fc55b1b8', + }, + { + name: 'discord', + metadata: { + id: '10124305123959553145', + icon: '889a29434mf7237dc6d4206caa93e79faf', + name: "ali's server", + selectedChannels: ['1118087567372455966', '1012430565959553148'], + period: '2022-12-01T00:00:00.000Z', + analyzerStartedAt: '2023-12-01T11:29:44.013Z', + }, + disconnectedAt: null, + id: '6569c3a1f89213542fc55b1b8', + }, + ], + users: ['user1', 'user2'], + avatarURL: 'url1', + }, + { + id: '2', + name: 'Community 2', + platforms: [ + { + name: 'discord', + metadata: { + id: '10124301222123959553145', + icon: '889a29434mf7237dc6d4206caa93e79faf', + name: "aliew's server", + selectedChannels: ['111808213567372455966', '1012430312459553148'], + period: '2022-12-01T00:00:00.000Z', + analyzerStartedAt: '2023-12-01T11:29:44.013Z', + }, + disconnectedAt: null, + id: '6569c3a1f849913542fc55b1b8', + }, + ], + users: ['user3', 'user4'], + avatarURL: 'url2', + }, + ]; + + const onSelectCommunityMock = jest.fn(); + + it('renders community items correctly', () => { + render( + + ); + expect(screen.getByText('Community 1')).toBeInTheDocument(); + expect(screen.getByText('Community 2')).toBeInTheDocument(); + }); + + it('calls onSelectCommunity when a community is clicked', () => { + render( + + ); + fireEvent.click(screen.getByText('Community 1')); + expect(onSelectCommunityMock).toHaveBeenCalledWith(mockCommunities[0]); + }); + + it('displays a message when no communities are available', () => { + render( + + ); + expect(screen.getByText('No community exist')).toBeInTheDocument(); + }); +}); diff --git a/src/components/centric/selectCommunity/TcSelectCommunity.tsx b/src/components/centric/selectCommunity/TcSelectCommunity.tsx new file mode 100644 index 00000000..dd3619ff --- /dev/null +++ b/src/components/centric/selectCommunity/TcSelectCommunity.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import TcText from '../../shared/TcText'; +import TcBoxContainer from '../../shared/TcBox/TcBoxContainer'; +import TcInput from '../../shared/TcInput'; +import TcCommunityList from './TcCommunityList'; +import TcButton from '../../shared/TcButton'; +import { BsPlus } from 'react-icons/bs'; +import router from 'next/router'; +import useAppStore from '../../../store/useStore'; +import Loading from '../../global/Loading'; +import { debounce } from '../../../helpers/helper'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; +import { StorageService } from '../../../services/StorageService'; +import SimpleBackdrop from '../../global/LoadingBackdrop'; +import { useToken } from '../../../context/TokenContext'; + +export interface CommunityData { + limit: number; + page: number; + results: any[]; + totalPages: number; + totalResults: number; +} + +function TcSelectCommunity() { + const { retrieveCommunities } = useAppStore(); + + const { updateCommunity } = useToken(); + + const [loading, setLoading] = useState(false); + const [communityLoading, setCommunityLoading] = useState(false); + const [activeCommunity, setActiveCommunity] = + useState(); + const [fetchedCommunities, setFetchedCommunities] = useState({ + limit: 10, + page: 1, + results: [], + totalPages: 0, + totalResults: 0, + }); + + const fetchCommunities = async (params: any) => { + setLoading(true); + const communities = await retrieveCommunities(params); + setFetchedCommunities(communities); + setLoading(false); + }; + + const debouncedFetchCommunities = debounce((value: string) => { + fetchCommunities({ page: 1, limit: 10, name: value }); + }, 300); + + useEffect(() => { + fetchCommunities({ page: 1, limit: 10 }); + }, []); + + const handleSelectedCommunity = () => { + setCommunityLoading(true); + if (activeCommunity) { + updateCommunity(activeCommunity); + StorageService.writeLocalStorage( + 'community', + activeCommunity + ); + router.push('/'); + } + }; + + if (communityLoading) { + return ; + } + + return ( +
+ + + + +
+ debouncedFetchCommunities(e.target.value)} + /> +
+ {loading ? ( + + ) : ( + + setActiveCommunity(community) + } + /> + )} + + } + className="md:w-3/5 mx-auto border border-custom-gray min-h-[20rem] max-h-[25rem] overflow-y-scroll rounded-lg" + /> + + + +
+ + + } + text="Create" + variant="outlined" + onClick={() => router.push('/centric/create-new-community')} + /> +
+ ); +} + +export default TcSelectCommunity; diff --git a/src/components/centric/selectCommunity/index.ts b/src/components/centric/selectCommunity/index.ts new file mode 100644 index 00000000..07e9c1e4 --- /dev/null +++ b/src/components/centric/selectCommunity/index.ts @@ -0,0 +1,3 @@ +import { default as TcSelectCommunity } from './TcSelectCommunity'; + +export default { TcSelectCommunity }; diff --git a/src/components/communitySettings/TcIntegrationCard.spec.tsx b/src/components/communitySettings/TcIntegrationCard.spec.tsx new file mode 100644 index 00000000..b2958cdf --- /dev/null +++ b/src/components/communitySettings/TcIntegrationCard.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import TcIntegrationCard from './TcIntegrationCard'; + +describe('', () => { + it('renders the card with passed children', () => { + // Mock child for testing + const mockChild =
Test Child
; + + render({mockChild}); + + // Check if the card contains the child content + const childElement = screen.getByText('Test Child'); + expect(childElement).toBeInTheDocument(); + }); + + it('applies the specified className to the card', () => { + render( + +
+ + ); + + // Using getByTestId as an example, but you might prefer another query method + const cardElement = screen.getByTestId('tc-integration-card'); + + expect(cardElement).toHaveClass('w-[8.75rem]', 'h-[10rem]'); + }); +}); diff --git a/src/components/communitySettings/TcIntegrationCard.tsx b/src/components/communitySettings/TcIntegrationCard.tsx new file mode 100644 index 00000000..287ae8b9 --- /dev/null +++ b/src/components/communitySettings/TcIntegrationCard.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import TcCard from '../shared/TcCard'; +import { CardProps } from '@mui/material'; + +interface ITcIntegrationCardProps extends CardProps { + children: React.ReactElement | JSX.Element; +} + +function TcIntegrationCard({ children, ...props }: ITcIntegrationCardProps) { + return ( + + ); +} + +export default TcIntegrationCard; diff --git a/src/components/communitySettings/communityIntegrations/TcAvailableIntegrations.spec.tsx b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrations.spec.tsx new file mode 100644 index 00000000..a9a238a4 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrations.spec.tsx @@ -0,0 +1,22 @@ +// TcAvailableIntegrations.test.js +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcAvailableIntegrations from './TcAvailableIntegrations'; +import { IntegrationPlatform } from '../../../utils/enums'; + +describe('', () => { + it('renders the TcAvailableIntegrationsItem component for each platform', () => { + const { getAllByTestId } = render(); + + // Assuming TcAvailableIntegrationsItem has a data-testid of "integration-item" + const items = getAllByTestId('integration-item'); + expect(items).toHaveLength(Object.values(IntegrationPlatform).length); + + // Extra: Check if each rendered item has the expected platform as prop + items.forEach((item, index) => { + const platform = Object.values(IntegrationPlatform)[index]; + expect(item).toHaveAttribute('data-platform', platform); + }); + }); +}); diff --git a/src/components/communitySettings/communityIntegrations/TcAvailableIntegrations.tsx b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrations.tsx new file mode 100644 index 00000000..b39020a7 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrations.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import TcAvailableIntegrationsItem from './TcAvailableIntegrationsItem'; +import { IntegrationPlatform } from '../../../utils/enums'; + +function TcAvailableIntegrations() { + return ( +
+ {Object.values(IntegrationPlatform).map((platform, index) => ( + + ))} +
+ ); +} + +export default TcAvailableIntegrations; diff --git a/src/components/communitySettings/communityIntegrations/TcAvailableIntegrationsItem.spec.tsx b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrationsItem.spec.tsx new file mode 100644 index 00000000..016bc3eb --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrationsItem.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcAvailableIntegrationsItem from './TcAvailableIntegrationsItem'; +import { IntegrationPlatform } from '../../../utils/enums'; + +describe('', () => { + test('it renders the correct integration platform text and icon', () => { + const { getByText, getByTestId } = render( + + ); + + expect(getByText(IntegrationPlatform.Discord)).toBeInTheDocument(); + }); + + test('it contains a connect button', () => { + const { getByText } = render( + + ); + + // Checking for the button + const buttonElement = getByText('Connect'); + expect(buttonElement).toBeInTheDocument(); + }); +}); diff --git a/src/components/communitySettings/communityIntegrations/TcAvailableIntegrationsItem.tsx b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrationsItem.tsx new file mode 100644 index 00000000..798a0092 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcAvailableIntegrationsItem.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import TcIntegrationCard from '../TcIntegrationCard'; +import TcButton from '../../shared/TcButton'; +import { BsPlus } from 'react-icons/bs'; +import TcText from '../../shared/TcText'; +import TcIntegrationIcon from './TcIntegrationIcon'; +import { IntegrationPlatform } from '../../../utils/enums'; +import useAppStore from '../../../store/useStore'; + +interface ITcAvailableIntegrationsItemProps { + integrationPlatform: IntegrationPlatform; + disabled?: boolean; +} + +function TcAvailableIntegrationsItem({ + integrationPlatform, + disabled = false, +}: ITcAvailableIntegrationsItemProps) { + const { connectNewPlatform } = useAppStore(); + + const integratePlatform = () => { + connectNewPlatform(integrationPlatform.toLocaleLowerCase()); + }; + + return ( +
+ +
+ +
+ +
+ } + className="max-w-full" + size="small" + onClick={() => integratePlatform()} + /> +
+
+ + {disabled && ( + + ); +} + +export default TcAvailableIntegrationsItem; diff --git a/src/components/communitySettings/communityIntegrations/TcCommunityIntegrations.spec.tsx b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrations.spec.tsx new file mode 100644 index 00000000..ad10a213 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrations.spec.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import TcCommunityIntegrations from './TcCommunityIntegrations'; + +describe('', () => { + it('renders TcConnectedPlatforms component', () => { + render(); + + // Let's use one of the unique text or elements from TcConnectedPlatforms to check if it's rendered. + // For instance, since we know "Discord" is a title in TcConnectedPlatforms, we can use that. + const allPlatformTitleElements = screen.getAllByText('Discord'); + expect(allPlatformTitleElements[0]).toBeInTheDocument(); + }); +}); diff --git a/src/components/communitySettings/communityIntegrations/TcCommunityIntegrations.tsx b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrations.tsx new file mode 100644 index 00000000..7f5ee6e2 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrations.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import TcConnectedPlatforms from './TcConnectedPlatforms'; +import TcAvailableIntegrations from './TcAvailableIntegrations'; +import TcText from '../../shared/TcText'; +import useAppStore from '../../../store/useStore'; +import { + FetchedData, + IDiscordModifiedCommunity, +} from '../../../utils/interfaces'; +import { StorageService } from '../../../services/StorageService'; +import Loading from '../../global/Loading'; + +function TcCommunityIntegrations() { + const { retrievePlatforms } = useAppStore(); + const [loading, setLoading] = useState(false); + const [fetchedPlatforms, setFetchedPlatforms] = useState({ + limit: 10, + page: 1, + results: [], + totalPages: 0, + totalResults: 0, + }); + + useEffect(() => { + const communityId = + StorageService.readLocalStorage( + 'community' + )?.id; + const fetchData = async () => { + try { + setLoading(true); + + const data = await retrievePlatforms({ + page: 1, + limit: 10, + community: communityId, + }); + + setFetchedPlatforms(data); + setLoading(false); + } catch (error) { + console.error('An error occurred while fetching platforms:', error); + setLoading(false); + } + }; + + fetchData(); + }, []); + + return ( + <> + +
+ {fetchedPlatforms?.results.length > 0 ? ( +
+ {loading ? ( + + ) : ( + <> + + + + )} +
+ ) : ( + '' + )} +
+ + +
+
+ + ); +} + +export default TcCommunityIntegrations; diff --git a/src/components/communitySettings/communityIntegrations/TcCommunityIntegrationsConfirmDialog.tsx b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrationsConfirmDialog.tsx new file mode 100644 index 00000000..f3b27f8c --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrationsConfirmDialog.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import TcDialog from '../../shared/TcDialog'; +import TcText from '../../shared/TcText'; +import TcButton from '../../shared/TcButton'; +import { RiTimeLine } from 'react-icons/ri'; +import { AiOutlineClose } from 'react-icons/ai'; +import Router from 'next/router'; + +interface ITcCommunityIntegrationsConfirmDialog { + isOpen: boolean; + toggleDialog: () => void; +} + +function TcCommunityIntegrationsConfirmDialog({ + isOpen, + toggleDialog, +}: ITcCommunityIntegrationsConfirmDialog) { + const handleCloseDialog = () => { + toggleDialog(); + Router.replace('/community-settings'); + }; + return ( + +
+ toggleDialog()} + /> +
+
+
+ +
+ + +
+ handleCloseDialog()} + /> +
+
+
+ ); +} + +export default TcCommunityIntegrationsConfirmDialog; diff --git a/src/components/communitySettings/communityIntegrations/TcCommunityIntegrationsDialog.tsx b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrationsDialog.tsx new file mode 100644 index 00000000..15d6fe9b --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcCommunityIntegrationsDialog.tsx @@ -0,0 +1,131 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { AiOutlineClose } from 'react-icons/ai'; +import TcDialog from '../../shared/TcDialog'; +import TcText from '../../shared/TcText'; +import TcPeriodRange from '../platform/TcPeriodRange'; +import TcButton from '../../shared/TcButton'; +import TcPlatformChannelDialog from '../platform/TcPlatformChannelDialog'; +import { useRouter } from 'next/router'; +import useAppStore from '../../../store/useStore'; +import { ChannelContext } from '../../../context/ChannelContext'; +import updateTrueIDs from '../../../helpers/PlatformHelper'; +import TcCommunityIntegrationsConfirmDialog from './TcCommunityIntegrationsConfirmDialog'; +import Loading from '../../global/Loading'; + +function TcCommunityIntegrationsDialog() { + const router = useRouter(); + const { platformId } = router.query; + + const { patchPlatformById } = useAppStore(); + const channelContext = useContext(ChannelContext); + const { selectedSubChannels } = channelContext; + + const [openDialog, setOpenDialog] = useState(false); + const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + + const [platfromAnalyzerDate, setPlatfromAnalyzerDate] = useState(''); + const [initialTrueIDs, setInitialTrueIDs] = useState([]); + const [currentTrueIDs, setCurrentTrueIDs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const { platformId, property } = router.query; + if (platformId && property) { + setOpenDialog(true); + } + }, [router.query]); + + useEffect(() => { + const updatedIDs = updateTrueIDs(selectedSubChannels); + if (initialTrueIDs.length === 0 && updatedIDs.length > 0) { + setInitialTrueIDs(updatedIDs); + } + setCurrentTrueIDs(updatedIDs); + }, [selectedSubChannels]); + + const handlePatchPlatform = async () => { + setLoading(true); + + await patchPlatformById({ + id: platformId, + metadata: { + selectedChannels: currentTrueIDs, + period: platfromAnalyzerDate, + analyzerStartedAt: new Date().toISOString(), + }, + }); + setOpenDialog(false); + setLoading(false); + setOpenConfirmDialog(true); + }; + + return ( +
+ + {loading ? ( +
+ +
+ ) : ( + <> + {' '} +
+ setOpenDialog(false)} + /> +
+
+
+ + + setPlatfromAnalyzerDate(date)} + /> +
+
+ +
+
+ +
+
+ + )} +
+ setOpenConfirmDialog(false)} + /> +
+ ); +} + +export default TcCommunityIntegrationsDialog; diff --git a/src/components/communitySettings/communityIntegrations/TcConnectedPlatforms.spec.tsx b/src/components/communitySettings/communityIntegrations/TcConnectedPlatforms.spec.tsx new file mode 100644 index 00000000..5a819c9d --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcConnectedPlatforms.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcConnectedPlatforms from './TcConnectedPlatforms'; + +// Mock the TcConnectedPlatformsItem component since it's imported and used in TcConnectedPlatforms +jest.mock('./TcConnectedPlatformsItem', () => { + return function MockTcConnectedPlatformsItem({ + platform, + }: { + platform: any; + }) { + return ( +
+ {/* Render the platform's name for testing purposes */} + {platform.name} +
+ ); + }; +}); + +describe('TcConnectedPlatforms', () => { + it('renders connected platforms correctly', () => { + // Define mock data for connected platforms + const connectedPlatforms = [ + { + name: 'Platform 1', + community: 'Community 1', + isInProgress: false, + connectedAt: '2023-01-01', + id: '1', + disconnectedAt: null, + metadata: {}, + }, + { + name: 'Platform 2', + community: 'Community 2', + isInProgress: true, + connectedAt: '2023-01-02', + id: '2', + disconnectedAt: null, + metadata: {}, + }, + ]; + + // Render the TcConnectedPlatforms component with the mock data + render(); + + // Check if the platform names are rendered correctly + const platform1Element = screen.getByText('Platform 1'); + const platform2Element = screen.getByText('Platform 2'); + + expect(platform1Element).toBeInTheDocument(); + expect(platform2Element).toBeInTheDocument(); + }); +}); diff --git a/src/components/communitySettings/communityIntegrations/TcConnectedPlatforms.tsx b/src/components/communitySettings/communityIntegrations/TcConnectedPlatforms.tsx new file mode 100644 index 00000000..f1d0abb0 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcConnectedPlatforms.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import TcConnectedPlatformsItem from './TcConnectedPlatformsItem'; +import { IPlatformProps } from '../../../utils/interfaces'; + +interface IConnectedPlatformsProps { + connectedPlatforms: IPlatformProps[]; +} + +function TcConnectedPlatforms({ + connectedPlatforms, +}: IConnectedPlatformsProps) { + return ( +
+ {connectedPlatforms?.map((platform: IPlatformProps, index: number) => ( + + ))} +
+ ); +} + +export default TcConnectedPlatforms; diff --git a/src/components/communitySettings/communityIntegrations/TcConnectedPlatformsItem.spec.tsx b/src/components/communitySettings/communityIntegrations/TcConnectedPlatformsItem.spec.tsx new file mode 100644 index 00000000..7e825980 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcConnectedPlatformsItem.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcConnectedPlatformsItem from './TcConnectedPlatformsItem'; + +describe('', () => { + const mockPlatform = { + name: 'Discord', + community: 'Mock Community', + isInProgress: false, + connectedAt: '2021-01-01', + id: '1', + disconnectedAt: null, + metadata: { + profileImageUrl: 'https://example.com/image.png', + name: 'Example Community', + username: 'exampleuser', + icon: 'icon-id', + }, + }; + + it('renders the platform title, status, and community info', () => { + render(); + + expect(screen.getByText('Discord')).toBeInTheDocument(); + }); +}); diff --git a/src/components/communitySettings/communityIntegrations/TcConnectedPlatformsItem.tsx b/src/components/communitySettings/communityIntegrations/TcConnectedPlatformsItem.tsx new file mode 100644 index 00000000..d0ab687e --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcConnectedPlatformsItem.tsx @@ -0,0 +1,138 @@ +/** + * TcConnectedPlatformsItem Component + * + * This component displays detailed information about a specific platform connected to a community. + * It includes the platform's name, connection status, and related community details. + * + * Props: + * - `platform` (IPlatformProps): An object containing the platform's details, including: + * - `name`: The name of the platform. + * - `community`: The name of the associated community. + * - `isInProgress`: Boolean indicating the connection status of the platform. + * - `connectedAt`: Date string representing when the platform was connected. + * - `id`: The unique identifier of the platform. + * - `disconnectedAt`: Date string or null, indicating when the platform was disconnected, if applicable. + * - `metadata`: An object containing additional metadata about the platform. + * + * The component visually represents the platform with an icon, the platform's name, and the community's information. + * It also provides a menu for additional actions, represented by a 'three dots' icon. + * + * @component + * @example + * const platform = { + * name: 'Discord', + * community: 'Example Community', + * isInProgress: false, + * connectedAt: '2021-01-01', + * id: '1', + * disconnectedAt: null, + * metadata: { + * profileImageUrl: 'https://example.com/image.png', + * name: 'Example Community', + * username: 'exampleuser', + * icon: 'icon-id' + * } + * }; + * + * + */ + +import React, { useState } from 'react'; +import TcText from '../../shared/TcText'; +import clsx from 'clsx'; +import TcAvatar from '../../shared/TcAvatar'; +import TcIntegrationCard from '../TcIntegrationCard'; +import router from 'next/router'; +import { conf } from '../../../configs'; +import { IPlatformProps } from '../../../utils/interfaces'; +import TcIntegrationIcon from './TcIntegrationIcon'; +import { capitalizeFirstChar, truncateCenter } from '../../../helpers/helper'; +import { IntegrationPlatform } from '../../../utils/enums'; +import { BsThreeDots } from 'react-icons/bs'; +import { ClickAwayListener, Tooltip } from '@mui/material'; + +interface TcConnectedPlatformsItemProps { + platform: IPlatformProps; +} + +function TcConnectedPlatformsItem({ platform }: TcConnectedPlatformsItemProps) { + const [open, setOpen] = useState(false); + + const handleTooltipClose = () => { + setOpen(false); + }; + + const handleTooltipOpen = () => { + setOpen(true); + }; + return ( + + <> +
+ + router.push(`/community-settings/platform/${platform.id}/`) + } + /> +
+
+
+
+ + + +
+ + +
+
+ +
+
+ {platform && ( +
+ + +
+ )} +
+ + + ); +} + +export default TcConnectedPlatformsItem; diff --git a/src/components/communitySettings/communityIntegrations/TcIntegrationIcon.spec.tsx b/src/components/communitySettings/communityIntegrations/TcIntegrationIcon.spec.tsx new file mode 100644 index 00000000..8764f139 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcIntegrationIcon.spec.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcIntegrationIcon from './TcIntegrationIcon'; +import { IntegrationPlatform } from '../../../utils/enums'; + +describe('', () => { + it('renders the correct icon for each platform', () => { + Object.values(IntegrationPlatform).forEach((platform) => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/communitySettings/communityIntegrations/TcIntegrationIcon.tsx b/src/components/communitySettings/communityIntegrations/TcIntegrationIcon.tsx new file mode 100644 index 00000000..31cdac8f --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/TcIntegrationIcon.tsx @@ -0,0 +1,54 @@ +/** + * TcIntegrationIcon Component. + * + * This component is responsible for displaying the correct icon + * based on the provided integration platform. + * + * The platform prop determines which icon will be rendered. + * If the platform does not match any of the predefined platforms, + * the component will render nothing. + * + * @component + * @example + * ```tsx + * + * ``` + * This will render the Discord icon. + * + * @param {TcIntegrationIconProps} props - Props for the component. + * @param {IntegrationPlatform} props.platform - The platform based on which the icon is rendered. + */ + +import React from 'react'; +import { + BsDiscord, + BsTwitter, + BsTelegram, + BsQuestionCircleFill, +} from 'react-icons/bs'; +import { BiLogoDiscourse } from 'react-icons/bi'; +import { IntegrationPlatform } from '../../../utils/enums'; + +interface ITcIntegrationIconProps { + platform: IntegrationPlatform; + size: number; +} + +function TcIntegrationIcon({ platform, size }: ITcIntegrationIconProps) { + switch (platform) { + case IntegrationPlatform.Discord: + return ; + case IntegrationPlatform.Twitter: + return ; + case IntegrationPlatform.Discourse: + return ; + case IntegrationPlatform.Telegram: + return ; + case IntegrationPlatform.Snapshot: + return ; + default: + return null; + } +} + +export default TcIntegrationIcon; diff --git a/src/components/communitySettings/communityIntegrations/index.ts b/src/components/communitySettings/communityIntegrations/index.ts new file mode 100644 index 00000000..3054a7b1 --- /dev/null +++ b/src/components/communitySettings/communityIntegrations/index.ts @@ -0,0 +1,3 @@ +import { default as TcCommunityIntegrations } from './TcCommunityIntegrations'; + +export default { TcCommunityIntegrations }; diff --git a/src/components/communitySettings/platform/TcCommunityName.tsx b/src/components/communitySettings/platform/TcCommunityName.tsx new file mode 100644 index 00000000..3b6fc2a3 --- /dev/null +++ b/src/components/communitySettings/platform/TcCommunityName.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import TcText from '../../shared/TcText'; +import TcAvatar from '../../shared/TcAvatar'; +import moment from 'moment'; +import Image from 'next/image'; +import { + IDiscordModifiedCommunity, + IPlatformProps, +} from '../../../utils/interfaces'; +import { MdGroups } from 'react-icons/md'; +import { useToken } from '../../../context/TokenContext'; +import { conf } from '../../../configs'; + +interface TccommunityName { + platform: IPlatformProps | null; +} + +function TcCommunityName({ platform }: TccommunityName) { + const { community } = useToken(); + + const renderPlatformAvatar = (community: IDiscordModifiedCommunity) => { + let activeCommunityPlatformIcon; + + if (community?.platforms) { + activeCommunityPlatformIcon = community.platforms.find( + (platform) => platform.disconnectedAt === null + ); + } + + if ( + activeCommunityPlatformIcon && + activeCommunityPlatformIcon.metadata && + activeCommunityPlatformIcon.metadata.icon + ) { + return ( + {activeCommunityPlatformIcon.metadata.name + ); + } + + return ; + }; + + return ( +
+ {community && community.avatarURL ? ( + + ) : ( + + {community ? renderPlatformAvatar(community) : } + + )} +
+ + +
+
+ ); +} + +export default TcCommunityName; diff --git a/src/components/communitySettings/platform/TcDisconnectPlatform.tsx b/src/components/communitySettings/platform/TcDisconnectPlatform.tsx new file mode 100644 index 00000000..bf6e7c13 --- /dev/null +++ b/src/components/communitySettings/platform/TcDisconnectPlatform.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import TcButton from '../../shared/TcButton'; +import { AiOutlineClose } from 'react-icons/ai'; +import TcDialog from '../../shared/TcDialog'; +import TcText from '../../shared/TcText'; +import { IPlatformProps } from '../../../utils/interfaces'; +import useAppStore from '../../../store/useStore'; +import { useRouter } from 'next/router'; +import { useSnackbar } from '../../../context/SnackbarContext'; + +interface TcDisconnectPlatformProps { + platform: IPlatformProps | null; +} + +function TcDisconnectPlatform({ platform }: TcDisconnectPlatformProps) { + const { deletePlatform } = useAppStore(); + const { showMessage } = useSnackbar(); + + const [openDialog, setOpenDialog] = useState(false); + const router = useRouter(); + const { id } = router.query; + + const handleDeletePlatform = async (deleteType: 'hard' | 'soft') => { + try { + await deletePlatform({ id, deleteType }); + setOpenDialog(false); + router.push('/community-settings'); + showMessage('Platform disconnected successfully.', 'success'); + } catch (error) {} + }; + + return ( + <> + setOpenDialog(true)} + /> + +
+ setOpenDialog(false)} + /> +
+
+
+ +
+
+
+ + + Importing activities and members will be stopped. Historical + activities will be deleted. + + } + variant="body2" + /> + handleDeletePlatform('hard')} + /> +
+
+ + + Importing activities and members will be stopped. Historical + activities will not be affected. + + } + variant="body2" + /> + handleDeletePlatform('soft')} + /> +
+
+
+
+ + ); +} + +export default TcDisconnectPlatform; diff --git a/src/components/communitySettings/platform/TcPeriodRange.tsx b/src/components/communitySettings/platform/TcPeriodRange.tsx new file mode 100644 index 00000000..b47bccd0 --- /dev/null +++ b/src/components/communitySettings/platform/TcPeriodRange.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import TcButtonGroup from '../../shared/TcButtonGroup'; +import TcButton from '../../shared/TcButton'; +import clsx from 'clsx'; + +type PeriodValue = 'Last 35 days' | '1M' | '3M' | '6M' | '1Y'; + +interface ITcPeriodRange { + activePeriod?: string; + handleSelectedDate: (date: string) => void; +} + +function TcPeriodRange({ handleSelectedDate, activePeriod }: ITcPeriodRange) { + const periods: PeriodValue[] = ['Last 35 days', '1M', '3M', '6M', '1Y']; + const calculateDaysDifference = (dateString: string): number => { + const date = new Date(dateString); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const diff = today.getTime() - date.getTime(); + + return Math.round(diff / (1000 * 3600 * 24)); + }; + + const findDefaultPeriod = (): PeriodValue => { + const fallbackDate = new Date(); + fallbackDate.setUTCHours(0, 0, 0, 0); + fallbackDate.setUTCDate(fallbackDate.getUTCDate() - 35); + + const daysDifference = calculateDaysDifference( + activePeriod ? activePeriod : fallbackDate.toISOString() + ); + + if (daysDifference > 30 && daysDifference <= 35) { + return 'Last 35 days'; + } else if (daysDifference <= 30) { + return '1M'; + } else if (daysDifference <= 90) { + return '3M'; + } else if (daysDifference <= 180) { + return '6M'; + } else { + return '1Y'; + } + }; + + const [selected, setSelected] = useState('Last 35 days'); + + useEffect(() => { + const newDefaultPeriod = findDefaultPeriod(); + setSelected(newDefaultPeriod); + const calculatedDateUTC = calculateDate(newDefaultPeriod); + handleSelectedDate(calculatedDateUTC); + }, [activePeriod]); + + useEffect(() => { + const calculatedDateUTC = calculateDate(selected); + handleSelectedDate(calculatedDateUTC); + }, [selected, handleSelectedDate]); + + const calculateDate = (value: PeriodValue): string => { + const currentDate = new Date(); + currentDate.setUTCHours(0, 0, 0, 0); + + switch (value) { + case 'Last 35 days': + currentDate.setUTCDate(currentDate.getUTCDate() - 35); + break; + case '1M': + currentDate.setUTCDate(currentDate.getUTCDate() - 30); + break; + case '3M': + currentDate.setUTCDate(currentDate.getUTCDate() - 90); + break; + case '6M': + currentDate.setUTCDate(currentDate.getUTCDate() - 180); + break; + case '1Y': + currentDate.setUTCDate(currentDate.getUTCDate() - 365); + break; + } + return currentDate.toISOString(); + }; + + const handleButtonClick = (value: PeriodValue) => { + setSelected(value); + const calculatedDateUTC = calculateDate(value); + handleSelectedDate(calculatedDateUTC); + }; + + return ( + + {periods.map((el) => ( + handleButtonClick(el)} + /> + ))} + + ); +} + +export default TcPeriodRange; diff --git a/src/components/communitySettings/platform/TcPlatform.tsx b/src/components/communitySettings/platform/TcPlatform.tsx new file mode 100644 index 00000000..f8c9fe4e --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatform.tsx @@ -0,0 +1,180 @@ +import React, { useContext, useEffect, useState } from 'react'; +import TcBoxContainer from '../../shared/TcBox/TcBoxContainer'; +import TcText from '../../shared/TcText'; +import TcButton from '../../shared/TcButton'; +import TcPlatformPeriod from './TcPlatformPeriod'; +import TcPlatformChannels from './TcPlatformChannels'; +import { useRouter } from 'next/router'; +import useAppStore from '../../../store/useStore'; +import TcDisconnectPlatform from './TcDisconnectPlatform'; +import TcCommunityName from './TcCommunityName'; +import { IPlatformProps } from '../../../utils/interfaces'; +import { ChannelContext } from '../../../context/ChannelContext'; +import updateTrueIDs from '../../../helpers/PlatformHelper'; +import SimpleBackdrop from '../../global/LoadingBackdrop'; +import TcCommunityIntegrationsConfirmDialog from '../communityIntegrations/TcCommunityIntegrationsConfirmDialog'; + +interface TcPlatformProps { + platformName?: string; +} + +function TcPlatform({ platformName = 'Discord' }: TcPlatformProps) { + const channelContext = useContext(ChannelContext); + + const { retrievePlatformById, patchPlatformById } = useAppStore(); + const [fetchedPlatform, setFetchedPlatform] = useState( + null + ); + const router = useRouter(); + + const [platfromAnalyzerDate, setPlatfromAnalyzerDate] = useState(''); + const [currentTrueIDs, setCurrentTrueIDs] = useState([]); + const [loading, setLoading] = useState(false); + const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + const [isChannelsFetched, setIsChannelFetched] = useState(true); + + const [initialPlatformAnalyzerDate, setInitialPlatformAnalyzerDate] = + useState(''); + const [initialTrueIDs, setInitialTrueIDs] = useState([]); + + const { refreshData, selectedSubChannels } = channelContext; + + const id = router.query.id as string; + + const fetchPlatform = async () => { + if (id) { + try { + setLoading(true); + const data = await retrievePlatformById(id); + setFetchedPlatform(data); + setLoading(false); + const { metadata } = data; + if (metadata) { + const { selectedChannels } = metadata; + + const data = await refreshData(id, 'channel', selectedChannels); + + if (data && data.length === 0) { + setIsChannelFetched(false); + } + } else { + const data = await refreshData(id, 'channel'); + if (data && data.length === 0) { + setIsChannelFetched(false); + } + } + } catch (error) { + } finally { + } + } + }; + + useEffect(() => { + fetchPlatform(); + }, [id, retrievePlatformById]); + + useEffect(() => { + if (isChannelsFetched) return; + + const fetchPlatformData = async () => { + if (!id) return; + + try { + const data = await retrievePlatformById(id); + setFetchedPlatform(data); + const { metadata } = data; + if (metadata) { + const { selectedChannels } = metadata; + await refreshData(id, 'channel', selectedChannels); + } else { + await refreshData(id); + } + setLoading(false); + } catch (error) {} + }; + + const intervalId = setInterval(() => { + fetchPlatformData(); + }, 5000); + + return () => clearInterval(intervalId); + }, [isChannelsFetched]); + + const handlePatchCommunity = async () => { + try { + const data = await patchPlatformById({ + id, + metadata: { + selectedChannels: currentTrueIDs, + period: platfromAnalyzerDate, + analyzerStartedAt: new Date().toISOString(), + }, + }); + if (data) { + setOpenConfirmDialog(true); + } + await fetchPlatform(); + } catch (error) {} + }; + + const handleDateChange = (date: string) => { + if (initialPlatformAnalyzerDate === '') + setInitialPlatformAnalyzerDate(date); + setPlatfromAnalyzerDate(date); + }; + + useEffect(() => { + const updatedIDs = updateTrueIDs(selectedSubChannels); + if (initialTrueIDs.length === 0 && updatedIDs.length > 0) { + setInitialTrueIDs(updatedIDs); + } + setCurrentTrueIDs(updatedIDs); + }, [selectedSubChannels]); + + if (loading) { + return ; + } + + const handleConfirmPlatformUpdate = () => { + setLoading(true); + setOpenConfirmDialog(false); + router.push('/community-settings'); + }; + + return ( + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ handleConfirmPlatformUpdate()} + /> +
+ } + /> + ); +} + +export default TcPlatform; diff --git a/src/components/communitySettings/platform/TcPlatformChannelDialog.tsx b/src/components/communitySettings/platform/TcPlatformChannelDialog.tsx new file mode 100644 index 00000000..edf07c3b --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformChannelDialog.tsx @@ -0,0 +1,80 @@ +import React, { useContext, useState } from 'react'; +import TcText from '../../shared/TcText'; +import TcButton from '../../shared/TcButton'; +import TcDialog from '../../shared/TcDialog'; +import { AiOutlineClose } from 'react-icons/ai'; +import TcPlatformChannelDialogHeader from './TcPlatformChannelDialogHeader'; +import TcPlatformChannelDialogBody from './TcPlatformChannelDialogBody'; +import TcPlatformChannelDialogFooter from './TcPlatformChannelDialogFooter'; +import { ChannelContext } from '../../../context/ChannelContext'; +import { calculateSelectedChannelSize } from '../../../helpers/helper'; + +function TcPlatformChannelDialog() { + const [openDialog, setOpenDialog] = useState(false); + const channelContext = useContext(ChannelContext); + + const { selectedSubChannels } = channelContext; + + const selectedCount = calculateSelectedChannelSize(selectedSubChannels); + + return ( +
+ +
+ + setOpenDialog(true)} + /> +
+ +
+ + setOpenDialog(false)} + /> +
+
+ + + +
+
+ setOpenDialog(false)} + /> +
+
+
+ ); +} + +export default TcPlatformChannelDialog; diff --git a/src/components/communitySettings/platform/TcPlatformChannelDialogBody.tsx b/src/components/communitySettings/platform/TcPlatformChannelDialogBody.tsx new file mode 100644 index 00000000..f4e5a677 --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformChannelDialogBody.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import TcPlatformChannelList from './TcPlatformChannelList'; + +function TcPlatformChannelDialogBody() { + return ( +
+ +
+ ); +} + +export default TcPlatformChannelDialogBody; diff --git a/src/components/communitySettings/platform/TcPlatformChannelDialogFooter.tsx b/src/components/communitySettings/platform/TcPlatformChannelDialogFooter.tsx new file mode 100644 index 00000000..3d90dd1f --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformChannelDialogFooter.tsx @@ -0,0 +1,59 @@ +import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material'; +import React from 'react'; +import { MdExpandMore } from 'react-icons/md'; + +function TcPlatformChannelDialogFooter() { + return ( +
+ + } + > +

+ How to give access to the channel you want to import? +

+
+ +
+
    +
  1. + Navigate to the channel you want to import on{' '} + + Discord + +
  2. +
  3. + Go to the settings for that specific channel (select the wheel + on the right of the channel name) +
  4. +
  5. + Select Permissions (left sidebar), and then in the middle + of the screen check Advanced permissions +
  6. +
  7. + With the TogetherCrew Bot selected, under Advanced + Permissions, make sure that [View channel] and [Read message + history] are marked as [✓] +
  8. +
  9. + Select the plus sign to the right of Roles/Members and under + members select TogetherCrew bot +
  10. +
  11. + Click on the Refresh List button on this window and + select the new channels +
  12. +
+
+
+
+
+ ); +} + +export default TcPlatformChannelDialogFooter; diff --git a/src/components/communitySettings/platform/TcPlatformChannelDialogHeader.tsx b/src/components/communitySettings/platform/TcPlatformChannelDialogHeader.tsx new file mode 100644 index 00000000..05efdafe --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformChannelDialogHeader.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import TcText from '../../shared/TcText'; + +function TcPlatformChannelDialogHeader() { + return ( +
+ +
+ ); +} + +export default TcPlatformChannelDialogHeader; diff --git a/src/components/communitySettings/platform/TcPlatformChannelList.tsx b/src/components/communitySettings/platform/TcPlatformChannelList.tsx new file mode 100644 index 00000000..f3e7ccb2 --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformChannelList.tsx @@ -0,0 +1,154 @@ +import React, { useContext } from 'react'; +import { FormControlLabel, FormGroup } from '@mui/material'; +import TcCheckbox from '../../shared/TcCheckbox'; +import TcText from '../../shared/TcText'; +import TcButton from '../../shared/TcButton'; +import { TbRefresh } from 'react-icons/tb'; +import Loading from '../../global/Loading'; +import { ChannelContext } from '../../../context/ChannelContext'; +import { useRouter } from 'next/router'; +import clsx from 'clsx'; +import { BiError } from 'react-icons/bi'; + +interface ITcPlatformChannelList { + refreshTrigger: boolean; + channelListCustomClass?: string; +} + +function TcPlatformChannelList({ + refreshTrigger = true, + channelListCustomClass, +}: ITcPlatformChannelList) { + const channelContext = useContext(ChannelContext); + const router = useRouter(); + + const id = Array.isArray(router.query.id) + ? router.query.id[0] + : router.query.id; + + const { + channels, + selectedSubChannels, + refreshData, + handleSubChannelChange, + handleSelectAll, + loading, + } = channelContext; + + const handleRefresh = () => { + if (id) { + refreshData(id); + } + }; + + if (loading) { + return ; + } + + if (channels.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {refreshTrigger ? ( + } + sx={{ maxWidth: '10rem' }} + variant="outlined" + onClick={handleRefresh} + text={'Refresh List'} + /> + ) : ( + '' + )} +
+ {channels && + channels?.map((channel, index) => ( +
+ +
+ + selectedSubChannels[channel.channelId]?.[ + subChannel.channelId + ] + )} + onChange={() => + handleSelectAll(channel.channelId, channel?.subChannels) + } + disabled={channel?.subChannels?.some( + (subChannel) => + !subChannel.canReadMessageHistoryAndViewChannel + )} + /> + } + label="All Channels" + /> + + + {channel.subChannels.map((subChannel, index) => ( +
+ + handleSubChannelChange( + channel.channelId, + subChannel.channelId + ) + } + /> + } + label={subChannel.name} + /> + {!subChannel.canReadMessageHistoryAndViewChannel ? ( +
+ + +
+ ) : ( + '' + )} +
+ ))} +
+
+
+ ))} +
+
+ ); +} + +export default TcPlatformChannelList; diff --git a/src/components/communitySettings/platform/TcPlatformChannels.tsx b/src/components/communitySettings/platform/TcPlatformChannels.tsx new file mode 100644 index 00000000..94af1031 --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformChannels.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { FaHashtag } from 'react-icons/fa6'; +import TcAvatar from '../../shared/TcAvatar'; +import TcPlatformChannelDialog from './TcPlatformChannelDialog'; + +function TcPlatformChannels() { + return ( +
+
+ + + +
+ +
+
+
+ ); +} + +export default TcPlatformChannels; diff --git a/src/components/communitySettings/platform/TcPlatformPeriod.tsx b/src/components/communitySettings/platform/TcPlatformPeriod.tsx new file mode 100644 index 00000000..a92b0dc4 --- /dev/null +++ b/src/components/communitySettings/platform/TcPlatformPeriod.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import TcAvatar from '../../shared/TcAvatar'; +import { MdOutlineCalendarToday } from 'react-icons/md'; +import { HiOutlineExclamationCircle } from 'react-icons/hi'; +import TcText from '../../shared/TcText'; +import TcPeriodRange from './TcPeriodRange'; +import { IPlatformProps } from '../../../utils/interfaces'; +import moment from 'moment'; + +interface ITcPlatformPeriodProps { + platform: IPlatformProps | null; + onDateChange: (date: string) => void; +} + +function TcPlatformPeriod({ onDateChange, platform }: ITcPlatformPeriodProps) { + const handleSelectedDate = (date: string) => { + onDateChange(date); + }; + + return ( +
+
+ + + +
+ + +
+ + +
+ +
+
+
+ ); +} + +export default TcPlatformPeriod; diff --git a/src/components/communitySettings/platform/index.ts b/src/components/communitySettings/platform/index.ts new file mode 100644 index 00000000..adf51ec1 --- /dev/null +++ b/src/components/communitySettings/platform/index.ts @@ -0,0 +1,3 @@ +import { default as TcPlatform } from './TcPlatform'; + +export default TcPlatform; diff --git a/src/components/communitySettings/switchCommunity/TcActiveCommunity.tsx b/src/components/communitySettings/switchCommunity/TcActiveCommunity.tsx new file mode 100644 index 00000000..45ef2e76 --- /dev/null +++ b/src/components/communitySettings/switchCommunity/TcActiveCommunity.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from 'react'; +import { StorageService } from '../../../services/StorageService'; +import useAppStore from '../../../store/useStore'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; +import TcAvatar from '../../shared/TcAvatar'; +import TcConfirmDeleteCommunity from './TcConfirmDeleteCommunity'; +import Loading from '../../global/Loading'; +import TcInput from '../../shared/TcInput'; +import { debounce } from '@mui/material'; +import { useSnackbar } from '../../../context/SnackbarContext'; +import { MdGroups } from 'react-icons/md'; +import { conf } from '../../../configs'; +import Image from 'next/image'; + +const updateCommunityName = debounce( + async (communityId, newName, updateFunc, fetchFunc, showSnackbar) => { + try { + await updateFunc({ + communityId, + name: newName.endsWith(' ') ? newName.slice(0, -1) : newName, + }); + await fetchFunc(); + showSnackbar('Community name updated successfully!', 'success'); + } catch (error) { + console.error('Error updating community name:', error); + showSnackbar('Failed to update community name', 'error'); + } + }, + 1000 +); + +function TcActiveCommunity() { + const { retrieveCommunityById, patchCommunityById } = useAppStore(); + const [loading, setLoading] = useState(false); + const [community, setCommunity] = useState( + null + ); + + const { showMessage } = useSnackbar(); + + useEffect(() => { + async function initFetch() { + try { + setLoading(true); + await fetchCommunity(); + } catch (error) { + console.error('Failed to fetch community data:', error); + } finally { + setLoading(false); + } + } + + initFetch(); + }, []); + + async function fetchCommunity() { + try { + const storedCommunityId = + StorageService.readLocalStorage( + 'community' + )?.id; + if (storedCommunityId) { + const fullCommunityData = await retrieveCommunityById( + storedCommunityId + ); + + setCommunity(fullCommunityData); + StorageService.writeLocalStorage('community', fullCommunityData); + } + } catch (error) { + StorageService.removeLocalStorage('community'); + setLoading(false); + console.error('Failed to fetch community data:', error); + } + } + + const handleCommunityNameChange = ( + event: React.ChangeEvent + ) => { + const newName = event.target.value; + if (community && community.id) { + setCommunity({ ...community, name: newName }); + updateCommunityName( + community.id, + newName, + patchCommunityById, + fetchCommunity, + showMessage + ); + } + }; + + const renderPlatformAvatar = (community: IDiscordModifiedCommunity) => { + let activeCommunityPlatformIcon; + + if (community?.platforms) { + activeCommunityPlatformIcon = community.platforms.find( + (platform) => platform.disconnectedAt === null + ); + } + + if ( + activeCommunityPlatformIcon && + activeCommunityPlatformIcon.metadata && + activeCommunityPlatformIcon.metadata.icon + ) { + return ( + {activeCommunityPlatformIcon.metadata.name + ); + } + + return ; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {community && community.avatarURL ? ( + + ) : ( + + {community ? ( + renderPlatformAvatar(community) + ) : ( + + )} + + )} + {loading ? ( + + ) : ( + + )} +
+ +
+ ); +} + +export default TcActiveCommunity; diff --git a/src/components/communitySettings/switchCommunity/TcConfirmDeleteCommunity.tsx b/src/components/communitySettings/switchCommunity/TcConfirmDeleteCommunity.tsx new file mode 100644 index 00000000..69f7355c --- /dev/null +++ b/src/components/communitySettings/switchCommunity/TcConfirmDeleteCommunity.tsx @@ -0,0 +1,173 @@ +import { AlertTitle } from '@mui/material'; +import React, { useState } from 'react'; +import { AiOutlineClose } from 'react-icons/ai'; +import { BiError } from 'react-icons/bi'; +import TcAlert from '../../shared/TcAlert'; +import TcButton from '../../shared/TcButton'; +import TcDialog from '../../shared/TcDialog'; +import TcText from '../../shared/TcText'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; +import TcInput from '../../shared/TcInput'; +import useAppStore from '../../../store/useStore'; +import SimpleBackdrop from '../../global/LoadingBackdrop'; +import Router from 'next/router'; +import { StorageService } from '../../../services/StorageService'; +import { useToken } from '../../../context/TokenContext'; + +interface CommunityComponentProps { + community: IDiscordModifiedCommunity | null; + handleUpdatePlatforms: () => void; +} + +function TcConfirmDeleteCommunity({ + community, + handleUpdatePlatforms, +}: CommunityComponentProps) { + const { deleteCommunityById } = useAppStore(); + const { deleteCommunity } = useToken(); + const [loading, setLoading] = useState(false); + const [activeStep, setActiveStep] = useState<1 | 2>(1); + const [openDialog, setOpenDialog] = useState(false); + const [communityNameInput, setCommunityNameInput] = useState(''); + const [isInputValid, setIsInputValid] = useState(false); + + const handleInputChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + setCommunityNameInput(inputValue); + setIsInputValid(inputValue === community?.name); + }; + + const handleDeleteCommunity = () => { + if (isInputValid) { + setLoading(true); + deleteCommunityById(community?.id).then(() => { + StorageService.removeLocalStorage('community'); + }); + deleteCommunity(); + setLoading(false); + setActiveStep(1); + setOpenDialog(false); + setCommunityNameInput(''); + handleUpdatePlatforms(); + Router.push('/centric/select-community'); + } + }; + + if (loading) { + return ; + } + + return ( +
+ { + setOpenDialog(true); + }} + /> + +
+ setOpenDialog(false)} + /> +
+ {activeStep === 1 ? ( +
+
+
+ +
+ +
+ + + If you don't read this, unexpected bad things will happen!{' '} + + + + Once the community account is deleted, there is{' '} + no way back. If you delete your community + account, all data from the platforms you had connected will be{' '} + deleted from our databases. + + } + /> +
+ setOpenDialog(false)} + /> + setActiveStep(2)} + /> +
+
+ ) : ( +
+ +
+ +
+
+ setOpenDialog(false)} + /> + +
+
+ )} +
+
+ ); +} + +export default TcConfirmDeleteCommunity; diff --git a/src/components/communitySettings/switchCommunity/TcSwitchCommunity.tsx b/src/components/communitySettings/switchCommunity/TcSwitchCommunity.tsx new file mode 100644 index 00000000..bd1a1701 --- /dev/null +++ b/src/components/communitySettings/switchCommunity/TcSwitchCommunity.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import TcButton from '../../shared/TcButton'; +import TcActiveCommunity from './TcActiveCommunity'; +import router from 'next/router'; +import TcText from '../../shared/TcText'; + +function TcSwitchCommunity() { + return ( +
+
+ + router.push('/centric/select-community')} + /> +
+ +
+ ); +} + +export default TcSwitchCommunity; diff --git a/src/components/communitySettings/switchCommunity/index.ts b/src/components/communitySettings/switchCommunity/index.ts new file mode 100644 index 00000000..de767881 --- /dev/null +++ b/src/components/communitySettings/switchCommunity/index.ts @@ -0,0 +1,3 @@ +import { default as TcSwitchCommunity } from './TcSwitchCommunity'; + +export default { TcSwitchCommunity }; diff --git a/src/components/global/CustomButton.tsx b/src/components/global/CustomButton.tsx index 7037a62c..68b6361a 100644 --- a/src/components/global/CustomButton.tsx +++ b/src/components/global/CustomButton.tsx @@ -8,7 +8,7 @@ interface IButtonProps extends ButtonProps { export default function CustomButton({ classes, label, - ...rest + ...props }: IButtonProps) { return ( <> @@ -17,7 +17,7 @@ export default function CustomButton({ 'py-3 w-full md:w-[240px] rounded-md text-base border-1 border-black', classes )} - {...rest} + {...props} > {label} diff --git a/src/components/global/CustomModal.tsx b/src/components/global/CustomModal.tsx index eb463199..3209fa9a 100644 --- a/src/components/global/CustomModal.tsx +++ b/src/components/global/CustomModal.tsx @@ -1,5 +1,5 @@ -import { Dialog, DialogTitle, DialogContent } from "@mui/material"; -import { IoClose } from "react-icons/io5"; +import { Dialog, DialogTitle, DialogContent } from '@mui/material'; +import { IoClose } from 'react-icons/io5'; type IModalProps = { isOpen: boolean; @@ -12,7 +12,7 @@ export default function ConfirmModal({ toggleModal, children, hasClose, - ...rest + ...props }: IModalProps) { const handleClose = () => { toggleModal(false); @@ -23,21 +23,21 @@ export default function ConfirmModal({ open={isOpen} onClose={handleClose} sx={{ - "& .MuiDialog-container": { - alignItems: "flex-start", - verticalAlign: "top", - "& .MuiPaper-root": { - width: "100%", - maxWidth: "650px", - borderRadius: "10px", - overflow:'visible' + '& .MuiDialog-container': { + alignItems: 'flex-start', + verticalAlign: 'top', + '& .MuiPaper-root': { + width: '100%', + maxWidth: '650px', + borderRadius: '10px', + overflow: 'visible', + }, + '& .MuiDialogContent-root': { + overflow: 'visible', }, - "& .MuiDialogContent-root": { - overflow: 'visible' - } }, }} - {...rest} + {...props} > {hasClose ? ( ) : ( - "" + '' )} {children} diff --git a/src/components/global/EmptyState.tsx b/src/components/global/EmptyState.tsx index 98582bbf..51b72b05 100644 --- a/src/components/global/EmptyState.tsx +++ b/src/components/global/EmptyState.tsx @@ -5,9 +5,15 @@ type IProps = { image: JSX.Element; title: string; description: string; + customButtonLabel: string; }; -export default function EmptyState({ image, title, description }: IProps) { +export default function EmptyState({ + image, + title, + description, + customButtonLabel, +}: IProps) { const router = useRouter(); return (
@@ -15,10 +21,10 @@ export default function EmptyState({ image, title, description }: IProps) {

{title}

{description}

{ - router.push('/settings'); + router.push('/community-settings'); }} />
@@ -29,4 +35,5 @@ EmptyState.defaultProps = { title: 'Almost there!', description: "To get an overview of your member's insights, community health, and more, connect your community.", + customButtonLabel: 'Connect your community', }; diff --git a/src/components/global/FilterByChannels.tsx b/src/components/global/FilterByChannels.tsx index 82c6e669..5aacda02 100644 --- a/src/components/global/FilterByChannels.tsx +++ b/src/components/global/FilterByChannels.tsx @@ -1,164 +1,27 @@ import { Popover } from '@mui/material'; -import React, { useEffect, useState } from 'react'; +import React, { useContext } from 'react'; import { FaHashtag } from 'react-icons/fa'; -import ChannelList from '../pages/login/ChannelList'; -import { StorageService } from '../../services/StorageService'; -import { - IChannel, - IChannelWithoutId, - IGuild, - ISubChannels, - IUser, -} from '../../utils/types'; import CustomButton from './CustomButton'; -import { IGuildChannels } from '../../utils/types'; -import useAppStore from '../../store/useStore'; import { BiError } from 'react-icons/bi'; +import { ChannelContext } from '../../context/ChannelContext'; +import TcPlatformChannelList from '../communitySettings/platform/TcPlatformChannelList'; +import { calculateSelectedChannelSize } from '../../helpers/helper'; -type IProps = { - guildChannels: IGuildChannels[]; - filteredChannels: string[]; - handleSelectedChannels: (selectedChannels: string[]) => void; +type IFilterByChannelsProps = { + handleFetchHeatmapByChannels?: () => void; }; const FilterByChannels = ({ - guildChannels, - filteredChannels, - handleSelectedChannels, -}: IProps) => { - const { guildInfo } = useAppStore(); - const [guild, setGuild] = useState(); - const [channels, setChannels] = useState>([]); - const [selectedChannels, setSelectedChannels] = useState< - Array - >([]); + handleFetchHeatmapByChannels, +}: IFilterByChannelsProps) => { + const channelContext = useContext(ChannelContext); + + const { selectedSubChannels } = channelContext; + const [anchorEl, setAnchorEl] = React.useState( null ); - useEffect(() => { - const user = StorageService.readLocalStorage('user'); - if (user) { - setGuild(user.guild); - } - let activeChannles: string[] = []; - - if (filteredChannels.length > 0) { - activeChannles = - guildInfo && guildInfo.selectedChannels - ? guildInfo.selectedChannels - .filter((channel: IChannel) => { - return ( - filteredChannels.includes(channel.channelId) ?? - channel.channelId - ); - }) - .map((channel: IChannel) => { - return channel.channelId; - }) - : []; - } else { - activeChannles = - guildInfo && guildInfo.selectedChannels - ? guildInfo.selectedChannels.map((channel: IChannel) => { - return channel.channelId; - }) - : []; - } - - const channels = guildChannels.map( - (guild: IGuildChannels, _index: number) => { - const selected: Record = {}; - - guild.subChannels.forEach((subChannel: ISubChannels) => { - if (activeChannles.includes(subChannel.channelId)) { - selected[subChannel.channelId] = true; - } else { - selected[subChannel.channelId] = false; - } - }); - - return { ...guild, selected: selected ?? {} }; - } - ); - - const subChannelsStatus = channels.map((channel: IGuildChannels) => { - return channel.selected; - }); - - const selectedChannelsStatus = Object.assign({}, ...subChannelsStatus); - let activeChannel: string[] = []; - for (const key in selectedChannelsStatus) { - if (selectedChannelsStatus[key]) { - activeChannel.push(key); - } - } - - const result = ([] as IChannelWithoutId[]).concat( - ...channels.map((channel: IGuildChannels) => { - return channel.subChannels - .filter((subChannel: ISubChannels) => { - if (activeChannel.includes(subChannel.channelId)) { - return subChannel; - } - }) - .map((filterdItem: ISubChannels) => { - return { - channelId: filterdItem.channelId, - channelName: filterdItem.name, - }; - }); - }) - ); - - setSelectedChannels(result); - - setChannels(channels); - }, [guildChannels]); - - const onChange = ( - channelId: string, - subChannelId: string, - status: boolean - ) => { - setChannels((preChannels) => { - return preChannels.map((preChannel) => { - if (preChannel.channelId !== channelId) return preChannel; - - const selected = preChannel.selected ?? {}; - - selected[subChannelId] = status; - - return { ...preChannel, selected }; - }); - }); - }; - const handleCheckAll = (guild: IGuildChannels, status: boolean) => { - const selectedGuild = channels.find( - (channel) => channel.channelId === guild.channelId - ); - if (!selectedGuild) return; - - const updatedChannels = channels.map((channel: IGuildChannels) => { - if (channel === selectedGuild) { - const selected = { ...channel.selected }; - Object.keys(selected).forEach((key) => (selected[key] = status)); - return { ...channel, selected }; - } - return channel; - }); - - setChannels(updatedChannels); - }; - const checkSelectedProperties = (channels: IGuildChannels[]) => { - return channels.every((channel) => { - const selectedValues = channel.selected - ? Object.values(channel.selected) - : []; - return selectedValues.every((selected) => !selected); - }); - }; - const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -167,44 +30,7 @@ const FilterByChannels = ({ setAnchorEl(null); }; - const returnSelectedChannelsId = (channels: IGuildChannels[]) => { - const subChannelsStatus = channels.map((channel: IGuildChannels) => { - return channel.selected; - }); - - const selectedChannelsStatus = Object.assign({}, ...subChannelsStatus); - let activeChannel: string[] = []; - if (selectedChannelsStatus) { - for (const key in selectedChannelsStatus) { - if (selectedChannelsStatus[key]) { - activeChannel.push(key); - } - } - } - - const result = ([] as IChannelWithoutId[]).concat( - ...channels.map((channel: IGuildChannels) => { - return channel.subChannels - .filter((subChannel: ISubChannels) => { - if (activeChannel.includes(subChannel.channelId)) { - return subChannel; - } - }) - .map((filterdItem: ISubChannels) => { - return { - channelId: filterdItem.channelId, - channelName: filterdItem.name, - }; - }); - }) - ); - - setSelectedChannels(result); - - return result.map((channel: IChannelWithoutId) => { - return channel.channelId; - }); - }; + const selectedCount = calculateSelectedChannelSize(selectedSubChannels); const open = Boolean(anchorEl); const id = open ? 'simple-popover' : undefined; @@ -217,7 +43,7 @@ const FilterByChannels = ({ onClick={handleClick} className="hover:bg-lite active:bg-white px-2 rounded-md" > - By channel ({selectedChannels.length}){' '} + By channel ({selectedCount}){' '} Select channels to view activity

-
- {channels && channels.length > 0 - ? channels.map((guild: IGuildChannels, index: number) => { - return ( -
- -
- ); - }) - : ''} +
+
@@ -262,11 +79,14 @@ const FilterByChannels = ({
{ - handleSelectedChannels(returnSelectedChannelsId(channels)); + if (handleFetchHeatmapByChannels) { + handleFetchHeatmapByChannels(); + } + setAnchorEl(null); }} - disabled={checkSelectedProperties(channels) ?? true} + disabled={selectedCount === 0} + classes="bg-secondary text-white mx-auto" />
diff --git a/src/components/global/FilterPopover/FilterRolesPopover.tsx b/src/components/global/FilterPopover/FilterRolesPopover.tsx new file mode 100644 index 00000000..dc93783e --- /dev/null +++ b/src/components/global/FilterPopover/FilterRolesPopover.tsx @@ -0,0 +1,435 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + MdOutlineKeyboardArrowUp, + MdOutlineKeyboardArrowDown, +} from 'react-icons/md'; +import TcButton from '../../shared/TcButton'; +import TcPopover from '../../shared/TcPopover'; +import { + FormControl, + RadioGroup, + FormControlLabel, + Radio, + ListItem, + Chip, + Autocomplete, +} from '@mui/material'; +import TcText from '../../shared/TcText'; +import TcInput from '../../shared/TcInput'; +import useAppStore from '../../../store/useStore'; +import { useToken } from '../../../context/TokenContext'; +import { debounce, hexToRGBA, isDarkColor } from '../../../helpers/helper'; +import { FetchedData, IRoles } from '../../../utils/interfaces'; +import TcCheckbox from '../../shared/TcCheckbox'; +import Loading from '../Loading'; +import { IRolesPayload } from '../../pages/statistics/memberBreakdowns/CustomTable'; + +function createPayload( + includeExclude: 'include' | 'exclude', + selectedRoles: string[] +): IRolesPayload { + if (!Array.isArray(selectedRoles)) { + throw new Error('selectedRoles must be an array of strings'); + } + + const payload: IRolesPayload = { + allRoles: false, + }; + + if (includeExclude === 'exclude') { + payload.exclude = selectedRoles; + } else { + payload.include = selectedRoles; + } + + return payload; +} + +interface IFilterRolesPopover { + handleSelectedRoles: (payload: IRolesPayload) => void; +} + +function FilterRolesPopover({ handleSelectedRoles }: IFilterRolesPopover) { + const [isRolesPopupOpen, setIsRolesPopupOpen] = useState(false); + + const handleButtonClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + setIsRolesPopupOpen(true); + }; + + const [anchorEl, setAnchorEl] = useState(null); + + const handleClosePopover = () => { + setIsRolesPopupOpen(false); + setAnchorEl(null); + }; + + const [includeExclude, setIncludeExclude] = useState<'include' | 'exclude'>( + 'include' + ); + + const handleIncludeExcludeChange = ( + event: React.ChangeEvent + ) => { + const newValue = event.target.value; + + if (newValue === 'include' || newValue === 'exclude') { + setIncludeExclude(newValue); + } + }; + + const { retrievePlatformProperties } = useAppStore(); + const { community } = useToken(); + const platformId = community?.platforms[0]?.id; + + const [loading, setLoading] = useState(false); + + const [fetchedRoles, setFetchedRoles] = useState({ + limit: 8, + page: 1, + results: [], + totalPages: 0, + totalResults: 0, + }); + const [page, setPage] = useState(1); + const [filteredRolesByName, setFilteredRolesByName] = useState(''); + + const fetchDiscordRoles = async ( + platformId: string, + page?: number, + limit?: number, + name?: string + ) => { + try { + setLoading(true); + const fetchedRoles = await retrievePlatformProperties({ + platformId, + name: name, + property: 'role', + page: page, + limit: limit, + }); + + if (name) { + setFilteredRolesByName(name); + setFetchedRoles(fetchedRoles); + } else { + setFetchedRoles((prevData) => { + const updatedResults = [ + ...prevData.results, + ...fetchedRoles.results, + ].filter( + (role, index, self) => + index === self.findIndex((r) => r.id === role.id) + ); + + return { + ...prevData, + ...fetchedRoles, + results: updatedResults, + }; + }); + } + setLoading(false); + } catch (error) {} + }; + + const handleClearAll = () => { + if (!platformId) return; + fetchDiscordRoles(platformId, fetchedRoles.page, fetchedRoles.limit); + }; + + const debouncedFetchDiscordRoles = debounce(fetchDiscordRoles, 700); + + useEffect(() => { + if (!platformId) return; + fetchDiscordRoles(platformId, fetchedRoles.page, fetchedRoles.limit); + }, []); + + const scrollableDivRef = useRef(null); + + const handleScroll = () => { + const element = scrollableDivRef.current; + if (!element) return; + + const hasReachedBottom = + element.scrollHeight - element.scrollTop === element.clientHeight; + + const hasMoreRolesToLoad = + fetchedRoles.page * fetchedRoles.limit <= fetchedRoles.totalResults; + + if (!platformId) return; + + if (hasReachedBottom && hasMoreRolesToLoad) { + const nextPage = fetchedRoles.page + 1; + if (filteredRolesByName) { + fetchDiscordRoles( + platformId, + nextPage, + fetchedRoles.limit, + filteredRolesByName + ); + } else { + fetchDiscordRoles(platformId, nextPage, fetchedRoles.limit); + } + setPage(nextPage); + } + }; + + const [selectedRoles, setSelectedRoles] = useState([]); + + useEffect(() => { + let payload; + + if (selectedRoles.length !== 0) { + const roleIds = selectedRoles.map((role) => role.roleId.toString()); + + payload = createPayload(includeExclude, roleIds); + } else { + payload = { allRoles: true }; + } + + handleSelectedRoles(payload); + }, [selectedRoles, includeExclude]); + + const toggleRole = (role: IRoles) => { + setSelectedRoles((prevSelectedRoles) => { + const isRoleSelected = prevSelectedRoles.some( + (selectedRole) => selectedRole.id === role.id + ); + + if (isRoleSelected) { + setFilteredRolesByName(''); + return prevSelectedRoles.filter( + (selectedRole) => selectedRole.id !== role.id + ); + } else { + setFilteredRolesByName(''); + return [...prevSelectedRoles, role]; + } + }); + }; + + const [isAutocompleteOpen, setAutocompleteOpen] = useState(false); + + const handleSearchChange = (event: React.ChangeEvent) => { + const inputValue = event.target.value; + + if (!platformId) return; + + if (inputValue === '') { + setFilteredRolesByName(''); + setFetchedRoles({ + limit: 8, + page: 1, + results: [], + totalPages: 0, + totalResults: 0, + }); + + debouncedFetchDiscordRoles(platformId, 1, 8); + } else { + debouncedFetchDiscordRoles(platformId, 1, 100, inputValue); + } + }; + + const renderRoleItem = (option: IRoles) => ( +
+ +
{option.name}
+
+ ); + + return ( +
+ + ) : ( + + ) + } + sx={{ + width: 'auto', + }} + onClick={handleButtonClick} + /> + + {selectedRoles.length > 0 && ( + + + } + label={} + className="w-1/2" + /> + } + label={} + /> + + + )} + + { + setSelectedRoles(newValue); + if (newValue.length === 0) { + handleClearAll(); + } + }} + getOptionLabel={(option) => option.name} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + { + e.stopPropagation(); + setAutocompleteOpen(false); + }} + /> + )} + /> + +
+ {fetchedRoles && fetchedRoles.results.length > 0 ? ( + <> + + {fetchedRoles?.results?.map((role: IRoles) => ( + + selectedRole.id === role.id + )} + onChange={() => toggleRole(role)} + /> + } + label={ + role ? ( + + ) : ( + '' + ) + } + className="w-full flex justify-start" + /> + + ))} + + ) : ( + + )} + {loading ? : ''} +
+
+ } + onClose={handleClosePopover} + /> +
+ ); +} + +export default FilterRolesPopover; diff --git a/src/components/global/Link.tsx b/src/components/global/Link.tsx index ba5455b1..eaff6fc8 100644 --- a/src/components/global/Link.tsx +++ b/src/components/global/Link.tsx @@ -5,9 +5,9 @@ type CustomLinkProps = MuiLinkProps & { to: string; }; -const Link: React.FC = ({ to, children, ...rest }) => { +const Link: React.FC = ({ to, children, ...props }) => { return ( - + {children} ); diff --git a/src/components/global/Loading.tsx b/src/components/global/Loading.tsx index 13ff17c6..311dd147 100644 --- a/src/components/global/Loading.tsx +++ b/src/components/global/Loading.tsx @@ -1,10 +1,11 @@ import { Box, CircularProgress } from '@mui/material'; interface LoadingProps { - height: string; + height?: string; + size?: number | string; } -function Loading({ height }: LoadingProps) { +function Loading({ height = '10rem', size = 40 }: LoadingProps) { return ( - + ); } -Loading.defaultProps = { - height: '10rem', -}; - export default Loading; diff --git a/src/components/global/ZonePicker.tsx b/src/components/global/ZonePicker.tsx index 19fbef9e..250edc9c 100644 --- a/src/components/global/ZonePicker.tsx +++ b/src/components/global/ZonePicker.tsx @@ -19,8 +19,6 @@ const ZonePicker = ({ selectedZone, handleSelectedZone }: IProps) => { null ); - // let [selectedZone, setSelectedZone] = useState(defaultTimeZone); - const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -72,12 +70,13 @@ const ZonePicker = ({ selectedZone, handleSelectedZone }: IProps) => { style: { width: '26rem' }, }} > -
+
{ >
{el}
-
+
{moment.tz(moment(), el).format('z,Z')}
{moment.tz(moment(), el).format('H a')}
diff --git a/src/components/layouts/Sidebar.tsx b/src/components/layouts/Sidebar.tsx index de2cab35..89a91172 100644 --- a/src/components/layouts/Sidebar.tsx +++ b/src/components/layouts/Sidebar.tsx @@ -10,36 +10,33 @@ type items = { import { conf } from '../../configs/index'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -// import the icons you need -import { - faUserGroup, - faHeartPulse, - faGear, -} from '@fortawesome/free-solid-svg-icons'; +import { faUserGroup, faHeartPulse } from '@fortawesome/free-solid-svg-icons'; import { useRouter } from 'next/router'; import Link from 'next/link'; -import { Tooltip, Typography } from '@mui/material'; -import useAppStore from '../../store/useStore'; -import { StorageService } from '../../services/StorageService'; -import { IUser } from '../../utils/types'; +import { FiSettings } from 'react-icons/fi'; +import { ICommunityDiscordPlatfromProps } from '../../utils/interfaces'; +import { useToken } from '../../context/TokenContext'; const Sidebar = () => { - const { guildInfoByDiscord } = useAppStore(); - const [guildId, setGuildId] = useState(''); const router = useRouter(); const currentRoute = router.pathname; + const { community } = useToken(); + + const [connectedPlatform, setConnectedPlatform] = + useState(null); useEffect(() => { - const user = StorageService.readLocalStorage('user'); + const storedCommunity = community; + + if (storedCommunity?.platforms) { + const foundPlatform = storedCommunity.platforms.find( + (platform) => platform.disconnectedAt === null + ); - if (user) { - const { guildId } = user.guild; - if (guildId) { - setGuildId(guildId); - } + setConnectedPlatform(foundPlatform ?? null); } - }, []); + }, [community]); const menuItems: items[] = [ { @@ -54,7 +51,7 @@ const Sidebar = () => { }, { name: 'Community Health', - path: '/communityHealth', + path: '/community-health', icon: ( { ), }, { - name: 'Settings', - path: '/settings', + name: 'Community Settings', + path: '/community-settings', icon: ( - ), }, @@ -97,13 +93,22 @@ const Sidebar = () => {
-
- {guildId && guildInfoByDiscord.icon ? ( +
router.push('/centric/select-community')} + > + {connectedPlatform && + connectedPlatform.metadata && + connectedPlatform.metadata.icon ? ( {guildInfoByDiscord.name ) : ( @@ -111,9 +116,6 @@ const Sidebar = () => { )}
-

- {guildInfoByDiscord.name} -


diff --git a/src/components/layouts/shared/TcPrompt.spec.tsx b/src/components/layouts/shared/TcPrompt.spec.tsx new file mode 100644 index 00000000..deeffcc3 --- /dev/null +++ b/src/components/layouts/shared/TcPrompt.spec.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcPrompt from './TcPrompt'; +import mockRouter from 'next-router-mock'; +import { StorageService } from '../../../services/StorageService'; + +jest.mock('next/router', () => require('next-router-mock')); +jest.mock('../../../services/StorageService'); + +describe('TcPrompt', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(StorageService, 'readLocalStorage').mockImplementation((key) => { + if (key === 'community') { + return { platforms: [] }; // Adjust this return value as needed for different test cases + } + return undefined; + }); + }); + + test('renders without crashing', () => { + mockRouter.setCurrentUrl('/'); + render(); + // Additional checks can be added here + }); + + test('renders prompt when no platforms are connected', () => { + mockRouter.setCurrentUrl('/'); + render(); + expect( + screen.getByText('To see the data, connect your community platforms.') + ).toBeInTheDocument(); + }); + + test('does not render prompt on excluded routes', () => { + mockRouter.setCurrentUrl('/cetric'); + render(); + expect( + screen.queryByText('To see the data, connect your community platforms.') + ).not.toBeInTheDocument(); + }); + + test('does not render prompt when platforms are connected', () => { + mockRouter.setCurrentUrl('/'); + jest + .spyOn(StorageService, 'readLocalStorage') + .mockReturnValue({ platforms: ['Discord'] }); + render(); + expect( + screen.queryByText('To see the data, connect your community platforms.') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/layouts/shared/TcPrompt.tsx b/src/components/layouts/shared/TcPrompt.tsx new file mode 100644 index 00000000..7cea43d6 --- /dev/null +++ b/src/components/layouts/shared/TcPrompt.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useMemo } from 'react'; +import TcAlert from '../../shared/TcAlert'; +import TcButton from '../../shared/TcButton'; +import TcCollapse from '../../shared/TcCollapse'; +import TcText from '../../shared/TcText'; +import { useRouter } from 'next/router'; +import { StorageService } from '../../../services/StorageService'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; + +function TcPrompt() { + const router = useRouter(); + const community = + StorageService.readLocalStorage('community'); + const shouldShowPrompt = useMemo(() => { + const currentRoute = router.pathname; + const isExcludedRoute = + currentRoute.startsWith('/cetric') || + currentRoute.startsWith('/community-settings'); + const hasNoPlatforms = community?.platforms.length === 0; + + return !isExcludedRoute && hasNoPlatforms; + }, [router.pathname, community?.platforms]); + + if (!shouldShowPrompt) { + return null; + } + + const promptData = { + backgroundColor: 'bg-orange', + message: 'To see the data, connect your community platforms.', + buttonText: 'Connect Platform', + redirectRouteParams: '/?platform=Discord', + }; + + const { backgroundColor, message, buttonText, redirectRouteParams } = + promptData; + + return ( + + +
+ + + router.push(`/community-settings${redirectRouteParams}`) + } + sx={{ + border: '1px solid white', + color: 'white', + paddingY: '0', + '&:hover': { + background: 'white', + border: '1px solid white', + color: 'black', + }, + }} + /> +
+
+
+ ); +} + +export default TcPrompt; diff --git a/src/components/layouts/xs/SidebarXs.tsx b/src/components/layouts/xs/SidebarXs.tsx index 7cf21649..ea5ad58a 100644 --- a/src/components/layouts/xs/SidebarXs.tsx +++ b/src/components/layouts/xs/SidebarXs.tsx @@ -9,12 +9,7 @@ type items = { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -// import the icons you need -import { - faUserGroup, - faHeartPulse, - faGear, -} from '@fortawesome/free-solid-svg-icons'; +import { faUserGroup, faHeartPulse } from '@fortawesome/free-solid-svg-icons'; import { useRouter } from 'next/router'; import Link from 'next/link'; @@ -22,27 +17,31 @@ import { Drawer } from '@mui/material'; import { FaBars } from 'react-icons/fa'; import { MdKeyboardBackspace } from 'react-icons/md'; -import useAppStore from '../../../store/useStore'; -import { StorageService } from '../../../services/StorageService'; -import { IUser } from '../../../utils/types'; import { conf } from '../../../configs'; +import { FiSettings } from 'react-icons/fi'; +import { useToken } from '../../../context/TokenContext'; +import { ICommunityDiscordPlatfromProps } from '../../../utils/interfaces'; const Sidebar = () => { - const { guildInfoByDiscord } = useAppStore(); - const [guildId, setGuildId] = useState(''); const router = useRouter(); const currentRoute = router.pathname; + const { community } = useToken(); + + const [connectedPlatform, setConnectedPlatform] = + useState(null); + useEffect(() => { - const user = StorageService.readLocalStorage('user'); + const storedCommunity = community; + + if (storedCommunity?.platforms) { + const foundPlatform = storedCommunity.platforms.find( + (platform) => platform.disconnectedAt === null + ); - if (user) { - const { guildId } = user.guild; - if (guildId) { - setGuildId(guildId); - } + setConnectedPlatform(foundPlatform ?? null); } - }, []); + }, [community]); const menuItems: items[] = [ { @@ -57,7 +56,7 @@ const Sidebar = () => { }, { name: 'Community Health', - path: '/communityHealth', + path: '/community-health', icon: ( { ), }, { - name: 'Settings', - path: '/settings', + name: 'Community Settings', + path: '/community-settings', icon: ( - ), }, @@ -111,12 +109,18 @@ const Sidebar = () => {
- {guildId && guildInfoByDiscord.icon ? ( + {connectedPlatform && + connectedPlatform.metadata && + connectedPlatform.metadata.icon ? ( {guildInfoByDiscord.name ) : ( @@ -124,7 +128,6 @@ const Sidebar = () => { )}
-

{guildInfoByDiscord.name}

diff --git a/src/components/pages/communitySettings/TcIntegrationDialog.spec.tsx b/src/components/pages/communitySettings/TcIntegrationDialog.spec.tsx new file mode 100644 index 00000000..61d9a99e --- /dev/null +++ b/src/components/pages/communitySettings/TcIntegrationDialog.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, fireEvent, getByText } from '@testing-library/react'; +import TcIntegrationDialog from './TcIntegrationDialog'; + +describe('', () => { + it('renders the dialog with the provided content', () => { + const mockOnClose = jest.fn(); + const { getByText } = render( + Test Content

} + buttonText="Test Button" + onClose={mockOnClose} + /> + ); + + // Check if title is rendered + expect(getByText('Test Title')).toBeInTheDocument(); + + // Check if content is rendered + expect(getByText('Test Content')).toBeInTheDocument(); + + // Check if button is rendered + expect(getByText('Test Button')).toBeInTheDocument(); + }); +}); diff --git a/src/components/pages/communitySettings/TcIntegrationDialog.tsx b/src/components/pages/communitySettings/TcIntegrationDialog.tsx new file mode 100644 index 00000000..6bfe2520 --- /dev/null +++ b/src/components/pages/communitySettings/TcIntegrationDialog.tsx @@ -0,0 +1,92 @@ +/** + * TcIntegrationDialog Component + * + * This component renders a dialog that provides instructions on how to integrate + * different platforms with your community. It uses Material-UI's Dialog component + * for the core functionality. + * + * Props: + * - `title` (string): The title text to display at the top of the dialog. + * - `showDialog` (boolean): Determines if the dialog should be displayed or not. + * - `bodyContent` (JSX.Element): The content to display inside the dialog. + * - `buttonText` (string): Text to display inside the action button. + * - `onClose` (function): A function that gets called when the close icon is clicked. + * + * Example: + * ```tsx + * + *

1 / Go to Twitter...

+ *

2 / Once you are connected...

+ * + * } + * buttonText="Connect Twitter account" + * onClose={() => {}} + * /> + * ``` + */ + +import React from 'react'; +import { IoCloseSharp } from 'react-icons/io5'; +import TcButton from '../../shared/TcButton'; +import TcText from '../../shared/TcText'; +import TcDialog from '../../shared/TcDialog'; + +interface IntegrationDialogProps { + title: string; + showDialog: boolean; + bodyContent: JSX.Element; + buttonText: string; + onClose: () => void; +} + +function TcIntegrationDialog({ + title, + showDialog, + bodyContent, + buttonText, + onClose, + ...props +}: IntegrationDialogProps) { + return ( + +
+ +
+
+ + {bodyContent} +
+ +
+
+
+ ); +} + +export default TcIntegrationDialog; diff --git a/src/components/pages/pageIndex/ActiveMemberComposition.tsx b/src/components/pages/pageIndex/ActiveMemberComposition.tsx index 0ab0370c..02fd7d64 100644 --- a/src/components/pages/pageIndex/ActiveMemberComposition.tsx +++ b/src/components/pages/pageIndex/ActiveMemberComposition.tsx @@ -3,29 +3,29 @@ import StatisticalData from '../statistics/StatisticalData'; import useAppStore from '../../../store/useStore'; import CustomButton from '../../global/CustomButton'; import { useRouter } from 'next/router'; -import { StorageService } from '../../../services/StorageService'; -import { IUser } from '../../../utils/types'; import moment from 'moment'; import { StatisticsProps } from '../../../utils/interfaces'; +import { useToken } from '../../../context/TokenContext'; const ActiveMemberComposition = () => { + const { community } = useToken(); + const router = useRouter(); - const [active, setActive] = useState(1); const { fetchActiveMembers, activeMembers } = useAppStore(); const [statistics, setStatistics] = useState([]); useEffect(() => { - const user = StorageService.readLocalStorage('user'); let endDate: moment.Moment = moment().subtract(1, 'day'); let startDate: moment.Moment = moment(endDate).subtract(7, 'days'); - if (user) { - const { guild } = user; - fetchActiveMembers(guild.guildId, startDate, endDate); + + const platformId = community?.platforms[0]?.id; + + if (platformId) { + fetchActiveMembers(platformId, startDate, endDate); } }, []); useEffect(() => { - // Copy options on each changes setStatistics([ { label: 'Active Members', diff --git a/src/components/pages/pageIndex/HeatmapChart.tsx b/src/components/pages/pageIndex/HeatmapChart.tsx index 472000a9..a371f2e4 100644 --- a/src/components/pages/pageIndex/HeatmapChart.tsx +++ b/src/components/pages/pageIndex/HeatmapChart.tsx @@ -1,12 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import Highcharts from 'highcharts'; import HighchartsHeatmap from 'highcharts/modules/heatmap'; import HighchartsReact from 'highcharts-react-official'; -import moment, { Moment } from 'moment'; +import moment from 'moment'; import 'moment-timezone'; -import SimpleBackdrop from '../../global/LoadingBackdrop'; -import { StorageService } from '../../../services/StorageService'; -import { IGuildChannels, IUser } from '../../../utils/types'; import NumberOfMessages from './NumberOfMessages'; import RangeSelect from '../../global/RangeSelect'; import ZonePicker from '../../global/ZonePicker'; @@ -14,409 +11,94 @@ import FilterByChannels from '../../global/FilterByChannels'; import useAppStore from '../../../store/useStore'; import { FiCalendar } from 'react-icons/fi'; import { communityActiveDates } from '../../../lib/data/dateRangeValues'; -import * as Sentry from '@sentry/nextjs'; import { transformToMidnightUTC } from '../../../helpers/momentHelper'; +import { useToken } from '../../../context/TokenContext'; +import { defaultHeatmapChartOptions } from '../../../lib/data/heatmap'; +import { ChannelContext } from '../../../context/ChannelContext'; +import { extractTrueSubChannelIds } from '../../../helpers/helper'; +import { StorageService } from '../../../services/StorageService'; +import { IDiscordModifiedCommunity } from '../../../utils/interfaces'; +import Loading from '../../global/Loading'; if (typeof Highcharts === 'object') { HighchartsHeatmap(Highcharts); } -const WEEK_DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; +type HeatmapDataPoint = [number, number, number | null]; +type HeatmapData = HeatmapDataPoint[]; -const HOURE_DAYS = Array.from({ length: 24 }, (_, i) => i).map(String); +const HeatmapChart = () => { + const channelContext = useContext(ChannelContext); -const defaultHeatmapChartOptions = { - chart: { - type: 'heatmap', - plotBorderWidth: 0, - }, - title: { - text: null, - }, - legend: { - enabled: false, - }, - xAxis: { - categories: HOURE_DAYS, - tickInterval: 1, - labels: { - step: 1, - style: { - fontSize: '14px', - fontFamily: 'Inter', - }, - }, - opposite: true, - gridLineWidth: 0, - lineWidth: 0, - lineColor: 'rgba(0,0,0,0.75)', - tickWidth: 0, - tickLength: 0, - tickColor: 'rgba(0,0,0,0.75)', - title: { - text: 'Hour', - style: { - fontSize: '14px', - fontFamily: 'Inter', - }, - align: 'low', - }, - }, - yAxis: { - categories: WEEK_DAYS, - lineWidth: 0, - gridLineWidth: 0, - title: 'Weekdays', - reversed: true, - labels: { - style: { - fontSize: '14px', - fontFamily: 'Inter', - }, - }, - }, - tooltip: { - enabled: false, - }, - colorAxis: { - min: 0, - minColor: '#F3F3F3', - maxColor: '#45367B', - max: 100, - stops: [ - [0, '#F3F3F3'], - [10 / 100, '#F3F3F3'], - [10 / 100, '#E3E9FF'], - [20 / 100, '#E3E9FF'], - [20 / 100, '#C5D2FF'], - [30 / 100, '#C5D2FF'], - [30 / 100, '#9971E7'], - [50 / 100, '#9971E7'], - [50 / 100, '#673FB5'], - [70 / 100, '#673FB5'], - [70 / 100, '#35205E'], - [1, '#35205E'], - ].map(([position, color]) => [position, color] as [number, string]), - }, - series: [ - { - name: 'Revenue', - borderWidth: 0.5, - borderColor: 'white', - states: { - hover: { - enabled: false, - }, - }, - dataLabels: { - enabled: true, - style: { - fontSize: '14px', - fontFamily: 'Inter', - textOutline: 'none', - fontWeight: 'normal', - }, - }, - pointPadding: 1.5, - data: Array.from({ length: 24 * 7 }, (_, i) => [ - i % 24, - Math.floor(i / 24), - 0, - ]), - colsize: 0.9, - rowsize: 0.8, - }, - ], - responsive: { - rules: [ - { - condition: { - maxWidth: 600, - }, - // Make the labels less space demanding on mobile - chartOptions: { - chart: { - scrollablePlotArea: { - minWidth: 1080, - }, - }, - legend: { - title: { - text: 'Number of interactions', - style: { - fontStyle: 'bold', - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - align: 'left', - layout: 'horizental', - margin: 0, - verticalAlign: 'bottom', - y: 0, - x: 25, - symbolHeight: 20, - }, - xAxis: { - width: 1000, - labels: { - step: 1, - style: { - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - }, - yAxis: { - labels: { - style: { - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - }, - series: [ - { - name: 'Revenue', - borderWidth: 0.5, - borderColor: 'white', - dataLabels: { - enabled: true, - style: { - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - pointPadding: 0.8, - data: Array.from({ length: 24 * 7 }, (_, i) => [ - i % 24, - Math.floor(i / 24), - 0, - ]), - colsize: 0.9, - rowsize: 0.9, - }, - ], - }, - }, - ], - }, -}; + const { selectedSubChannels, refreshData } = channelContext; -const HeatmapChart = () => { + const { fetchHeatmapData, retrievePlatformById } = useAppStore(); + const [loading, setLoading] = useState(false); + const [activeDateRange, setActiveDateRange] = useState(1); + const [selectedZone, setSelectedZone] = useState(moment.tz.guess()); const [heatmapChartOptions, setHeatmapChartOptions] = useState( defaultHeatmapChartOptions ); - const [isLoading, setIsLoading] = useState(true); - const [user, setUser] = useState(null); - const [activeDateRange, setActiveDateRange] = useState(1); - const [dateRange, setDateRange] = useState< - [string | moment.Moment | null, string | moment.Moment | null] - >([null, null]); - const [selectedZone, setSelectedZone] = useState(moment.tz.guess()); - const [channels, setChannels] = useState([]); - - const { - fetchHeatmapData, - getUserGuildInfo, - fetchGuildChannels, - selectedChannelsList, - getSelectedChannelsList, - } = useAppStore(); - - useEffect(() => { - const fetchData = async () => { - const storedUser = StorageService.readLocalStorage('user'); - if (!storedUser) { - return; // Exit early if there is no stored user - } - - setUser(storedUser); - - try { - const guildId = storedUser.guild.guildId; - getUserGuildInfo(guildId); - fetchGuildChannels(guildId); - const channelsList: IGuildChannels[] | [] = - await getSelectedChannelsList(guildId); + const defaultEndDate = moment().subtract(1, 'day'); + const defaultStartDate = moment(defaultEndDate).subtract(7, 'days'); - if (!Array.isArray(channelsList) || channelsList.length === 0) { - return; - } - - const defaultEndDate = moment().subtract(1, 'day'); - const defaultStartDate = moment(defaultEndDate).subtract(7, 'days'); - - setDateRange([ - transformToMidnightUTC(defaultStartDate).toString(), - transformToMidnightUTC(defaultEndDate).toString(), - ]); - - const channelIds = channelsList - .flatMap((channel) => channel.subChannels || []) // Flatten the subChannels arrays - .filter(Boolean) // Filter out falsy subChannels - .map((subChannel) => subChannel.channelId); + const [dateRange, setDateRange] = useState< + [string | moment.Moment, string | moment.Moment] + >([ + transformToMidnightUTC(defaultStartDate).toString(), + transformToMidnightUTC(defaultEndDate).toString(), + ]); - if (channelIds.length === 0) { - return; // Exit early if there are no valid subChannels - } + const { community } = useToken(); - setChannels(channelIds); + const platformId = community?.platforms[0]?.id; - await fetchHeatmapData( - guildId, - defaultStartDate, - defaultEndDate, + const fetchData = async () => { + setLoading(true); + try { + if (platformId) { + const data = await fetchHeatmapData( + platformId, + dateRange[0], + dateRange[1], selectedZone, - channelIds + extractTrueSubChannelIds(selectedSubChannels) ); - } catch (error: unknown) { - Sentry.captureException(error); // Handle any errors that occur - } finally { - setIsLoading(false); - } - }; - fetchData(); - }, []); - - useEffect(() => { - if (!user) { - return; - } - - const { guildId } = user.guild; - fetchHeatmap(guildId, dateRange[0], dateRange[1], selectedZone, channels); - }, [dateRange, selectedZone, channels]); - - const fetchHeatmap = async ( - guildId: string, - startDate: moment.Moment | string | null, - endDate: moment.Moment | string | null, - timezone: string, - channelIds: string[] - ) => { - if (!guildId || !startDate || !endDate) { - return; - } - - setIsLoading(true); - - try { - const res = await fetchHeatmapData( - guildId, - startDate, - endDate, - timezone, - channelIds - ); - setHeatmapChartOptions((prevOptions) => ({ - ...prevOptions, - series: [ - { - ...prevOptions.series[0], - data: res?.map((item: any[]) => [item[1], item[0], item[2] || 0]), - }, - ], - responsive: { - rules: [ - { - condition: { - maxWidth: 600, - }, - // Make the labels less space demanding on mobile - chartOptions: { - chart: { - scrollablePlotArea: { - minWidth: 1080, - }, - }, - legend: { - title: { - text: 'Number of interactions', - style: { - fontStyle: 'bold', - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - align: 'left', - layout: 'horizontal', - margin: 0, - verticalAlign: 'bottom', - y: 0, - x: 25, - symbolHeight: 20, - }, - xAxis: { - width: 1000, - labels: { - step: 1, - style: { - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - }, - yAxis: { - labels: { - style: { - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - }, - series: [ - { - name: 'Revenue', - borderWidth: 0.5, - borderColor: 'white', - dataLabels: { - enabled: true, - style: { - fontSize: '10px', - fontFamily: 'Inter', - }, - }, - pointPadding: 0.8, - data: res?.map((item: any[]) => [ - item[1], - item[0], - item[2] || 0, - ]), - colsize: 0.9, - rowsize: 0.9, - }, - ], - }, - }, - ], - }, - })); + updateHeatmapData(data); + } } catch (error) { - Sentry.captureException(error); // Capture and send the error to Sentry } finally { - setIsLoading(false); + setLoading(false); } }; - const handleSelectedZone = (zone: string) => { - setSelectedZone(zone); + const updateHeatmapData = (responseData: HeatmapData) => { + const newOptions = { + ...defaultHeatmapChartOptions, + series: [ + { + ...defaultHeatmapChartOptions.series[0], + data: responseData.map((item: HeatmapDataPoint) => [ + item[1], + item[0], + item[2] ?? 0, + ]), + }, + ], + }; + + setHeatmapChartOptions(newOptions); }; - const handleSelectedChannels = (selectedChannels: string[]) => { - setChannels(selectedChannels); - if (user) { - const { guildId } = user.guild; - fetchHeatmap( - guildId, - dateRange[0], - dateRange[1], - selectedZone, - selectedChannels - ); - } + useEffect(() => { + fetchPlatformChannels(); + fetchData(); + }, []); + + const handleSelectedZone = (zone: string) => { + setSelectedZone(zone); }; const handleDateRange = (dateRangeType: number): void => { @@ -461,7 +143,38 @@ const HeatmapChart = () => { } }; - if (isLoading) return ; + const handleFetchHeatmapByChannels = () => { + fetchData(); + }; + + const fetchPlatformChannels = async () => { + try { + const community = + StorageService.readLocalStorage('community'); + + const platformId = community?.platforms[0]?.id; + + if (platformId) { + const data = await retrievePlatformById(platformId); + const { metadata } = data; + if (metadata) { + const { selectedChannels } = metadata; + await refreshData(platformId, 'channel', selectedChannels); + } else { + await refreshData(platformId); + } + } + } catch (error) { + } finally { + } + }; + + useEffect(() => { + if (!platformId) { + return; + } + fetchData(); + }, [dateRange, selectedZone, platformId]); return (
@@ -493,9 +206,7 @@ const HeatmapChart = () => {
@@ -503,11 +214,22 @@ const HeatmapChart = () => {
- +
+ {loading && ( +
+ +
+ )} + +
+ +
+
+
diff --git a/src/components/pages/pageIndex/MainSection.tsx b/src/components/pages/pageIndex/MainSection.tsx deleted file mode 100644 index f4ea6d1b..00000000 --- a/src/components/pages/pageIndex/MainSection.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import ActiveMemberComposition from './ActiveMemberComposition'; -import HeatmapChart from './HeatmapChart'; -import MemberInteractionGraph from './MemberInteractionGraph'; - -const MainSection = () => { - return ( -
-

- Community Insights -

-
- - - -
-
- ); -}; - -export default MainSection; diff --git a/src/components/pages/settings/ConnectCommunities.tsx b/src/components/pages/settings/ConnectCommunities.tsx index 41de9044..19a3f4b3 100644 --- a/src/components/pages/settings/ConnectCommunities.tsx +++ b/src/components/pages/settings/ConnectCommunities.tsx @@ -6,23 +6,24 @@ import CustomButton from '../../global/CustomButton'; import DatePeriodRange from '../../global/DatePeriodRange'; import CustomModal from '../../global/CustomModal'; import ChannelSelection from './ChannelSelection'; -import { BsClockHistory } from 'react-icons/bs'; +import { BsClockHistory, BsTwitter } from 'react-icons/bs'; import useAppStore from '../../../store/useStore'; import { useRouter } from 'next/router'; import moment from 'moment'; import { StorageService } from '../../../services/StorageService'; import { IUser } from '../../../utils/types'; -import * as amplitude from '@amplitude/analytics-browser'; -import { IDecodedToken } from '../../../utils/interfaces'; -import jwt_decode from 'jwt-decode'; + import { setAmplitudeUserIdFromToken, trackAmplitudeEvent, } from '../../../helpers/amplitudeHelper'; +import { decodeUserTokenDiscordId } from '../../../helpers/helper'; export default function ConnectCommunities() { const router = useRouter(); + const user = StorageService.readLocalStorage('user'); + const [open, setOpen] = useState(false); const [confirmModalOpen, setConfirmModalOpen] = useState(false); const [guildId, setGuildId] = useState(''); @@ -30,8 +31,13 @@ export default function ConnectCommunities() { const [datePeriod, setDatePeriod] = useState(''); const [selectedChannels, setSelectedChannels] = useState([]); - const { guilds, connectNewGuild, patchGuildById, getUserGuildInfo } = - useAppStore(); + const { + guilds, + connectNewGuild, + patchGuildById, + getUserGuildInfo, + authorizeTwitter, + } = useAppStore(); if (typeof window !== 'undefined') { useEffect(() => { @@ -121,6 +127,15 @@ export default function ConnectCommunities() { getUserGuildInfo(guildId); setConfirmModalOpen(false); }; + + const handleAuthorizeTwitter = () => { + authorizeTwitter(decodeUserTokenDiscordId(user)); + }; + const isAllTwitterPropertiesNull = + user && + user.twitter && + Object.values(user.twitter).every((value) => value == null); + return ( <>
-
+

Connect your communities

- {guilds.length >= 1 ? ( - - It will be possible to connect more communities soon. - - } - arrow - placement="right" - > - -

Discord

- -
- -

Connect

-
-
-
- ) : ( - connectNewGuild()} - > -

Discord

- -
- -

Connect

+
+ {isAllTwitterPropertiesNull ? ( +
+ handleAuthorizeTwitter()} + > +

Twitter

+ +
+ +

Connect

+
+
- - )} + ) : ( + <> + )} +
+ {guilds.length >= 1 ? ( + + It will be possible to connect more communities soon. + + } + arrow + placement="right" + > + +

Discord

+ +
+ +

Connect

+
+
+
+ ) : ( + connectNewGuild()} + > +

Discord

+ +
+ +

Connect

+
+
+ )} +
+
diff --git a/src/components/pages/settings/ConnectedCommunitiesList.tsx b/src/components/pages/settings/ConnectedCommunitiesList.tsx index ab22c9f7..f1972bb2 100644 --- a/src/components/pages/settings/ConnectedCommunitiesList.tsx +++ b/src/components/pages/settings/ConnectedCommunitiesList.tsx @@ -8,12 +8,13 @@ import { Paper } from '@mui/material'; import useAppStore from '../../../store/useStore'; import { DISCONNECT_TYPE } from '../../../store/types/ISetting'; import { StorageService } from '../../../services/StorageService'; -import { IUser } from '../../../utils/types'; +import { ITwitter, IUser } from '../../../utils/types'; import { setAmplitudeUserIdFromToken, trackAmplitudeEvent, } from '../../../helpers/amplitudeHelper'; +import ConnectedTwitter from './ConnectedTwitter'; export default function ConnectedCommunitiesList({ guilds }: any) { const { disconnecGuildById, getGuilds } = useAppStore(); @@ -22,6 +23,7 @@ export default function ConnectedCommunitiesList({ guilds }: any) { const toggleModal = (e: boolean) => { setOpen(e); }; + let user: IUser | undefined = StorageService.readLocalStorage('user'); const notify = () => { toast('The integration has been disconnected succesfully.', { position: 'top-center', @@ -42,9 +44,6 @@ export default function ConnectedCommunitiesList({ guilds }: any) { notify(); getGuilds(); - let user: IUser | undefined = - StorageService.readLocalStorage('user'); - setAmplitudeUserIdFromToken(); trackAmplitudeEvent({ @@ -61,6 +60,15 @@ export default function ConnectedCommunitiesList({ guilds }: any) { }); }; + function isAllTwitterPropertiesNull(twitter: ITwitter): boolean { + return ( + twitter.twitterConnectedAt === null && + twitter.twitterId === null && + twitter.twitterProfileImageUrl === null && + twitter.twitterUsername === null + ); + } + return ( <> {guilds && guilds.length > 0 ? ( @@ -82,6 +90,13 @@ export default function ConnectedCommunitiesList({ guilds }: any) {
)) : ''} + {user?.twitter && !isAllTwitterPropertiesNull(user.twitter) ? ( +
+ +
+ ) : ( + <> + )}
) : ( diff --git a/src/components/pages/settings/ConnectedTwitter.tsx b/src/components/pages/settings/ConnectedTwitter.tsx new file mode 100644 index 00000000..de5be7b0 --- /dev/null +++ b/src/components/pages/settings/ConnectedTwitter.tsx @@ -0,0 +1,80 @@ +import { Avatar, Paper } from '@mui/material'; +import React from 'react'; +import { ITwitter } from '../../../utils/types'; +import useAppStore from '../../../store/useStore'; +import { BsTwitter } from 'react-icons/bs'; +import moment from 'moment'; +import { StorageService } from '../../../services/StorageService'; +import clsx from 'clsx'; + +interface IConnectedTwitter { + twitter?: ITwitter; +} + +function ConnectedTwitter({ twitter }: IConnectedTwitter) { + const { disconnectTwitter, getUserInfo } = useAppStore(); + + const handleDisconnect = async () => { + try { + await disconnectTwitter(); + + const userInfo = await getUserInfo(); + const { + twitterConnectedAt, + twitterId, + twitterProfileImageUrl, + twitterUsername, + } = userInfo; + + StorageService.updateLocalStorageWithObject('user', 'twitter', { + twitterConnectedAt, + twitterId, + twitterProfileImageUrl, + twitterUsername, + }); + + StorageService.removeLocalStorage('lastTwitterMetricsRefreshDate'); + } catch (error) { + console.error('Error handling disconnect:', error); + } + }; + + return ( +
+ +
+
+

Twitter

+ +
+ +
+
+ +
+

{twitter?.twitterUsername}

+

{`Connected ${moment( + twitter?.twitterConnectedAt + ).format('DD MMM yyyy')}`}

+
+
+
+ Disconnect +
+
+
+ ); +} + +export default ConnectedTwitter; diff --git a/src/components/pages/statistics/Onboarding.spec.tsx b/src/components/pages/statistics/Onboarding.spec.tsx index 3cce1606..4c6b55b2 100644 --- a/src/components/pages/statistics/Onboarding.spec.tsx +++ b/src/components/pages/statistics/Onboarding.spec.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import Onboarding from './Onboarding'; import { communityActiveDates } from '../../../lib/data/dateRangeValues'; +import { TokenProvider } from '../../../context/TokenContext'; jest.mock('next/router', () => require('next-router-mock')); describe('Onboarding component', () => { @@ -9,10 +10,12 @@ describe('Onboarding component', () => { beforeEach(() => { render( - + + + ); }); diff --git a/src/components/pages/statistics/StatisticalData.tsx b/src/components/pages/statistics/StatisticalData.tsx index db698baa..5dca4872 100644 --- a/src/components/pages/statistics/StatisticalData.tsx +++ b/src/components/pages/statistics/StatisticalData.tsx @@ -1,4 +1,4 @@ -import { Tooltip } from '@mui/material'; +import { ClickAwayListener, Tooltip } from '@mui/material'; import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; import { RxArrowTopRight, RxArrowBottomRight } from 'react-icons/rx'; @@ -25,6 +25,7 @@ const StatisticalData: React.FC = ({ handleSelectedOption, }) => { const [activeState, setActiveState] = useState(); + const [open, setOpen] = useState(false); useEffect(() => { const queries = router.query; @@ -55,6 +56,14 @@ const StatisticalData: React.FC = ({ } }, [router.query]); + const handleTooltipClose = () => { + setOpen(false); + }; + + const handleTooltipOpen = () => { + setOpen(true); + }; + return ( <>
= ({ {stat.label} {stat.hasTooltip && ( - - - - - + + + + + + + )}
diff --git a/src/components/pages/statistics/memberBreakdowns/CustomTable.tsx b/src/components/pages/statistics/memberBreakdowns/CustomTable.tsx index b22ad4a7..22992403 100644 --- a/src/components/pages/statistics/memberBreakdowns/CustomTable.tsx +++ b/src/components/pages/statistics/memberBreakdowns/CustomTable.tsx @@ -2,6 +2,7 @@ import { Avatar, Button, Checkbox, + ClickAwayListener, FormControlLabel, List, ListItem, @@ -29,13 +30,18 @@ import { Row, IActivityCompositionOptions, } from '../../../../utils/interfaces'; -import { IUser } from '../../../../utils/types'; import { conf } from '../../../../configs'; import Loading from '../../../global/Loading'; import useAppStore from '../../../../store/useStore'; -import { StorageService } from '../../../../services/StorageService'; import CustomDialogDetail from './CustomDialogDetail'; import router from 'next/router'; +import FilterRolesPopover from '../../../global/FilterPopover/FilterRolesPopover'; + +export interface IRolesPayload { + allRoles: boolean; + exclude?: string[]; + include?: string[]; +} interface CustomTableProps { data: Row[]; @@ -43,7 +49,7 @@ interface CustomTableProps { isLoading: boolean; breakdownName?: string; activityCompositionOptions: IActivityCompositionOptions[]; - handleRoleSelectionChange: (selectedRoles: string[]) => void; + handleRoleSelectionChange: (rolesPayload: IRolesPayload) => void; handleActivityOptionSelectionChange: (selectedRoles: string[]) => void; handleJoinedAtChange: (joinedAt: string) => void; handleUsernameChange: (userName: string) => void; @@ -54,43 +60,26 @@ const CustomTable: React.FC = ({ columns, isLoading, breakdownName, - handleRoleSelectionChange, handleActivityOptionSelectionChange, + handleRoleSelectionChange, handleJoinedAtChange, handleUsernameChange, activityCompositionOptions, }) => { - const { getRoles, roles } = useAppStore(); - useEffect(() => { - const user = StorageService.readLocalStorage('user'); - - if (!user) { - return; - } - - const { guild } = user; - getRoles(guild.guildId); - }, []); + const { roles } = useAppStore(); const [anchorElRoles, setAnchorElRoles] = useState( null ); const [anchorElActivity, setAnchorElActivity] = useState(null); - const [selectedRoles, setSelectedRoles] = useState([]); - useEffect(() => { - setSelectedRoles(roles.map((role: IRoles) => role.roleId)); - }, [roles]); - const [selectAllRoles, setSelectAllRoles] = useState(true); + const [selectedActivityOptions, setSelectedActivityOptions] = useState< string[] >(activityCompositionOptions.map((option) => option.value)); const [selectAllActivityOptions, setSelectAllActivityOptions] = useState(true); - - const handleOpenRolesPopup = (event: React.MouseEvent) => { - setAnchorElRoles(event.currentTarget); - }; + const [tooltipOpen, setTooltipOpen] = useState(false); const handleOpenActivityPopup = ( event: React.MouseEvent @@ -104,33 +93,12 @@ const CustomTable: React.FC = ({ setAnchorElJoinedAt(null); }; - const isRolesPopupOpen = Boolean(anchorElRoles); const isActivityPopupOpen = Boolean(anchorElActivity); - const handleSelectAllRoles = (event: React.ChangeEvent) => { - if (event.target.checked) { - const allRoleNames = roles.map((role: IRoles) => role.roleId); - setSelectedRoles(allRoleNames); - } else { - setSelectedRoles([]); - } - setSelectAllRoles(event.target.checked); - }; - - const handleSelectRole = (event: React.ChangeEvent) => { - const roleName = event.target.value; - const updatedSelectedRoles = selectedRoles.includes(roleName) - ? selectedRoles.filter((role) => role !== roleName) - : [...selectedRoles, roleName]; - - setSelectedRoles(updatedSelectedRoles); - setSelectAllRoles(updatedSelectedRoles.length === roles.length); + const handleSelectedRoles = (payload: IRolesPayload) => { + handleRoleSelectionChange(payload); }; - useEffect(() => { - handleRoleSelectionChange(selectedRoles); - }, [selectedRoles]); - useEffect(() => { handleActivityOptionSelectionChange(selectedActivityOptions); }, [selectedActivityOptions]); @@ -264,6 +232,14 @@ const CustomTable: React.FC = ({ } }, [router.query]); + const handleTooltipClose = () => { + setTooltipOpen(false); + }; + + const handleTooltipOpen = () => { + setTooltipOpen(true); + }; + return ( <> @@ -282,80 +258,9 @@ const CustomTable: React.FC = ({ > {column.id === 'roles' ? ( <> - - -
- - } - label={'All Roles'} - /> -

Show members with tags:

- {roles.map((role: IRoles) => ( - - - } - label={ -
- -
{role.name}
-
- } - /> -
- ))} -
-
+ ) : column.id === 'activityComposition' ? ( <> @@ -363,7 +268,7 @@ const CustomTable: React.FC = ({ onClick={handleOpenActivityPopup} size="small" variant="text" - sx={{ color: 'black' }} + sx={{ color: 'black', minWidth: '64px' }} endIcon={ isActivityPopupOpen ? ( @@ -401,7 +306,7 @@ const CustomTable: React.FC = ({ /> {activityCompositionOptions.map( (option: IActivityCompositionOptions) => ( - + = ({ onClick={handleOpenJoinedAtPopup} size="small" variant="text" - sx={{ color: 'black' }} + sx={{ color: 'black', minWidth: '64px' }} endIcon={ isJoinedAtPopupOpen ? ( @@ -467,6 +372,7 @@ const CustomTable: React.FC = ({ handleSortOptionClick('desc')} selected={selectedSortOption === 'desc'} > @@ -474,6 +380,7 @@ const CustomTable: React.FC = ({ handleSortOptionClick('asc')} selected={selectedSortOption === 'asc'} > @@ -499,7 +406,7 @@ const CustomTable: React.FC = ({ }} value={searchText} onChange={handleSearchChange} - sx={{ marginLeft: '8px' }} + sx={{ marginLeft: '8px', minWidth: 'auto' }} /> ) : ( @@ -528,7 +435,7 @@ const CustomTable: React.FC = ({ {data && data.length > 0 ? ( data.map((row, index) => ( = ({ {column.id === 'username' ? (

@@ -563,13 +474,21 @@ const CustomTable: React.FC = ({ {row.ngu} {row[column.id].length > 10 ? ( - - - {`${row[column.id].slice(0, 3)}...${row[ - column.id - ].slice(-4)}`} - - + + + + {`${row[column.id].slice(0, 3)}...${row[ + column.id + ].slice(-4)}`} + + + ) : ( {row[column.id]} diff --git a/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.spec.tsx b/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.spec.tsx index e175707b..822e50e2 100644 --- a/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.spec.tsx +++ b/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.spec.tsx @@ -1,12 +1,16 @@ import { render, screen } from '@testing-library/react'; import ActiveMemberBreakdown from './ActiveMemberBreakdown'; +import { TokenProvider } from '../../../../../context/TokenContext'; jest.mock('next/router', () => require('next-router-mock')); describe('ActiveMemberBreakdown', () => { it('renders the component', () => { - render(); + render( + + + + ); - // Assert the component is rendered const component = screen.getByText('Members breakdown'); expect(component).toBeInTheDocument(); }); diff --git a/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.tsx b/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.tsx index 9cee797f..c830e788 100644 --- a/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.tsx +++ b/src/components/pages/statistics/memberBreakdowns/activeMembers/ActiveMemberBreakdown.tsx @@ -1,10 +1,9 @@ import { useState, useEffect, useRef } from 'react'; -import { StorageService } from '../../../../../services/StorageService'; import useAppStore from '../../../../../store/useStore'; -import { IUser } from '../../../../../utils/types'; -import CustomTable from '../CustomTable'; +import CustomTable, { IRolesPayload } from '../CustomTable'; import { Column, + FetchedData, IActivityCompositionOptions, } from '../../../../../utils/interfaces'; import CustomPagination from '../CustomPagination'; @@ -17,6 +16,7 @@ import { downloadCSVFile, } from '../../../../../helpers/csvHelper'; import router from 'next/router'; +import { useToken } from '../../../../../context/TokenContext'; const columns: Column[] = [ { id: 'username', label: 'Name' }, @@ -36,31 +36,27 @@ const options: IActivityCompositionOptions[] = [ export default function ActiveMemberBreakdown() { const { getActiveMemberCompositionTable } = useAppStore(); + const { community } = useToken(); const tableTopRef = useRef(null); const [isExpanded, toggleExpanded] = useState(false); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState([]); + const [roles, setRoles] = useState(); const [activityComposition, setActivityComposition] = useState( options.map((option) => option.value) ); const [username, setUsername] = useState(''); const [sortBy, setSortBy] = useState('desc'); - const [fetchedData, setFetchedData] = useState<{ - limit?: string | number; - page?: string | number; - results: any[]; - totalPages: number; - totalResults: number; - }>({ + const [fetchedData, setFetchedData] = useState({ + limit: 10, + page: 1, results: [], - totalResults: 0, totalPages: 0, + totalResults: 0, }); - const user = StorageService.readLocalStorage('user'); - const guild = user?.guild; + const platformId = community?.platforms[0]?.id; const handlePageChange = (selectedPage: number) => { setPage(selectedPage); @@ -70,13 +66,14 @@ export default function ActiveMemberBreakdown() { }; useEffect(() => { - if (!guild) { + if (!platformId) { return; } + setLoading(true); const fetchData = async () => { const res = await getActiveMemberCompositionTable( - guild.guildId, + platformId, activityComposition, roles, username, @@ -125,8 +122,8 @@ export default function ActiveMemberBreakdown() { } }, [router.query]); - const handleRoleSelectionChange = (selectedRoles: string[]) => { - setRoles(selectedRoles); + const handleRoleSelectionChange = (rolesPayload: IRolesPayload) => { + setRoles(rolesPayload); }; const handleActivityOptionSelectionChange = (selectedOptions: string[]) => { @@ -149,7 +146,7 @@ export default function ActiveMemberBreakdown() { }; const handleDownloadCSV = async () => { - if (!guild) { + if (!platformId) { return; } @@ -157,7 +154,7 @@ export default function ActiveMemberBreakdown() { const limit = fetchedData.totalResults; const { results } = await getActiveMemberCompositionTable( - guild.guildId, + platformId, activityComposition, roles, username, @@ -191,6 +188,7 @@ export default function ActiveMemberBreakdown() { startIcon={} size="small" variant="outlined" + sx={{ minWidth: '64px', padding: '0.4rem 1rem' }} className="border-black text-black" disableElevation onClick={handleDownloadCSV} diff --git a/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.spec.tsx b/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.spec.tsx index 1ec85f55..cb15c3f9 100644 --- a/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.spec.tsx +++ b/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.spec.tsx @@ -1,12 +1,16 @@ import { render, screen } from '@testing-library/react'; +import { TokenProvider } from '../../../../../context/TokenContext'; import DisengagedMembersCompositionBreakdown from './DisengagedMembersCompositionBreakdown'; jest.mock('next/router', () => require('next-router-mock')); describe('ActiveMemberBreakdown', () => { it('renders the component', () => { - render(); + render( + + + + ); - // Assert the component is rendered const component = screen.getByText('Members breakdown'); expect(component).toBeInTheDocument(); }); diff --git a/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.tsx b/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.tsx index f4622493..e34bab2d 100644 --- a/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.tsx +++ b/src/components/pages/statistics/memberBreakdowns/disengagedMembersComposition/DisengagedMembersCompositionBreakdown.tsx @@ -1,10 +1,9 @@ import { useState, useEffect, useRef } from 'react'; -import { StorageService } from '../../../../../services/StorageService'; import useAppStore from '../../../../../store/useStore'; -import { IUser } from '../../../../../utils/types'; -import CustomTable from '../CustomTable'; +import CustomTable, { IRolesPayload } from '../CustomTable'; import { Column, + FetchedData, IActivityCompositionOptions, } from '../../../../../utils/interfaces'; import CustomPagination from '../CustomPagination'; @@ -17,6 +16,7 @@ import { downloadCSVFile, } from '../../../../../helpers/csvHelper'; import router from 'next/router'; +import { useToken } from '../../../../../context/TokenContext'; const columns: Column[] = [ { id: 'username', label: 'Name' }, @@ -47,31 +47,28 @@ const options: IActivityCompositionOptions[] = [ export default function DisengagedMembersCompositionBreakdown() { const { getDisengagedMembersCompositionTable } = useAppStore(); + const { community } = useToken(); const tableTopRef = useRef(null); const [isExpanded, toggleExpanded] = useState(false); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState([]); + const [roles, setRoles] = useState(); const [disengagedComposition, setDisengagedComposition] = useState( options.map((option) => option.value) ); const [username, setUsername] = useState(''); const [sortBy, setSortBy] = useState('desc'); - const [fetchedData, setFetchedData] = useState<{ - limit?: string | number; - page?: string | number; - results: any[]; - totalPages: number; - totalResults: number; - }>({ + const [fetchedData, setFetchedData] = useState({ + limit: 10, + page: 1, results: [], - totalResults: 0, totalPages: 0, + totalResults: 0, }); - const user = StorageService.readLocalStorage('user'); - const guild = user?.guild; + + const platformId = community?.platforms[0]?.id; const handlePageChange = (selectedPage: number) => { setPage(selectedPage); @@ -81,13 +78,13 @@ export default function DisengagedMembersCompositionBreakdown() { }; useEffect(() => { - if (!guild) { + if (!platformId) { return; } setLoading(true); const fetchData = async () => { const res = await getDisengagedMembersCompositionTable( - guild.guildId, + platformId, disengagedComposition, roles, username, @@ -136,8 +133,8 @@ export default function DisengagedMembersCompositionBreakdown() { } }, [router.query]); - const handleRoleSelectionChange = (selectedRoles: string[]) => { - setRoles(selectedRoles); + const handleRoleSelectionChange = (rolesPayload: IRolesPayload) => { + setRoles(rolesPayload); }; const handleActivityOptionSelectionChange = (selectedOptions: string[]) => { @@ -160,7 +157,7 @@ export default function DisengagedMembersCompositionBreakdown() { }; const handleDownloadCSV = async () => { - if (!guild) { + if (!platformId) { return; } @@ -168,7 +165,7 @@ export default function DisengagedMembersCompositionBreakdown() { const limit = fetchedData.totalResults; const { results } = await getDisengagedMembersCompositionTable( - guild.guildId, + platformId, disengagedComposition, roles, username, @@ -202,6 +199,7 @@ export default function DisengagedMembersCompositionBreakdown() { startIcon={} size="small" variant="outlined" + sx={{ minWidth: '64px', padding: '0.4rem 1rem' }} className="border-black text-black" disableElevation onClick={handleDownloadCSV} diff --git a/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.spec.tsx b/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.spec.tsx index 34274109..cfba1d8b 100644 --- a/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.spec.tsx +++ b/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.spec.tsx @@ -1,12 +1,16 @@ import { render, screen } from '@testing-library/react'; +import { TokenProvider } from '../../../../../context/TokenContext'; import OnboardingMembersBreakdown from './OnboardingMembersBreakdown'; jest.mock('next/router', () => require('next-router-mock')); describe('ActiveMemberBreakdown', () => { it('renders the component', () => { - render(); + render( + + + + ); - // Assert the component is rendered const component = screen.getByText('Members breakdown'); expect(component).toBeInTheDocument(); }); diff --git a/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.tsx b/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.tsx index 82652b62..57c1c62a 100644 --- a/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.tsx +++ b/src/components/pages/statistics/memberBreakdowns/onboardingMembers/OnboardingMembersBreakdown.tsx @@ -1,10 +1,9 @@ import { useState, useEffect, useRef } from 'react'; -import { StorageService } from '../../../../../services/StorageService'; import useAppStore from '../../../../../store/useStore'; -import { IUser } from '../../../../../utils/types'; -import CustomTable from '../CustomTable'; +import CustomTable, { IRolesPayload } from '../CustomTable'; import { Column, + FetchedData, IActivityCompositionOptions, } from '../../../../../utils/interfaces'; import CustomPagination from '../CustomPagination'; @@ -17,6 +16,7 @@ import { downloadCSVFile, } from '../../../../../helpers/csvHelper'; import router from 'next/router'; +import { useToken } from '../../../../../context/TokenContext'; const columns: Column[] = [ { id: 'username', label: 'Name' }, @@ -35,31 +35,28 @@ const options: IActivityCompositionOptions[] = [ export default function OnboardingMembersBreakdown() { const { getOnboardingMemberCompositionTable } = useAppStore(); + const { community } = useToken(); const tableTopRef = useRef(null); const [isExpanded, toggleExpanded] = useState(false); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState([]); + const [roles, setRoles] = useState(); const [onboardingComposition, setOnboardingComposition] = useState( options.map((option) => option.value) ); const [username, setUsername] = useState(''); const [sortBy, setSortBy] = useState('desc'); - const [fetchedData, setFetchedData] = useState<{ - limit?: string | number; - page?: string | number; - results: any[]; - totalPages: number; - totalResults: number; - }>({ + const [fetchedData, setFetchedData] = useState({ + limit: 10, + page: 1, results: [], - totalResults: 0, totalPages: 0, + totalResults: 0, }); - const user = StorageService.readLocalStorage('user'); - const guild = user?.guild; + + const platformId = community?.platforms[0]?.id; const handlePageChange = (selectedPage: number) => { setPage(selectedPage); @@ -69,13 +66,13 @@ export default function OnboardingMembersBreakdown() { }; useEffect(() => { - if (!guild) { + if (!platformId) { return; } setLoading(true); const fetchData = async () => { const res = await getOnboardingMemberCompositionTable( - guild.guildId, + platformId, onboardingComposition, roles, username, @@ -123,8 +120,9 @@ export default function OnboardingMembersBreakdown() { } } }, [router.query]); - const handleRoleSelectionChange = (selectedRoles: string[]) => { - setRoles(selectedRoles); + + const handleRoleSelectionChange = (rolesPayload: IRolesPayload) => { + setRoles(rolesPayload); }; const handleActivityOptionSelectionChange = (selectedOptions: string[]) => { @@ -147,7 +145,7 @@ export default function OnboardingMembersBreakdown() { }; const handleDownloadCSV = async () => { - if (!guild) { + if (!platformId) { return; } @@ -155,7 +153,7 @@ export default function OnboardingMembersBreakdown() { const limit = fetchedData.totalResults; const { results } = await getOnboardingMemberCompositionTable( - guild.guildId, + platformId, onboardingComposition, roles, username, @@ -189,6 +187,7 @@ export default function OnboardingMembersBreakdown() { startIcon={} size="small" variant="outlined" + sx={{ minWidth: '64px', padding: '0.4rem 1rem' }} className="border-black text-black" disableElevation onClick={handleDownloadCSV} diff --git a/src/components/shared/TcAlert.spec.tsx b/src/components/shared/TcAlert.spec.tsx new file mode 100644 index 00000000..4e83d18d --- /dev/null +++ b/src/components/shared/TcAlert.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcAlert from './TcAlert'; // Replace with the correct import path + +describe('TcAlert Component', () => { + it('renders the alert with custom message', () => { + const message = 'This is a test alert'; + + const { getByText } = render({message}); + + // Use the @testing-library/jest-dom assertions to check if the element is present + expect(getByText(message)).toBeInTheDocument(); + }); + + it('renders the alert with a custom severity', () => { + const message = 'Custom severity alert'; + const customSeverity = 'warning'; + + const { container } = render( + {message} + ); + + // Check if the element has the correct class based on the custom severity + expect(container).toHaveTextContent(message); + }); +}); diff --git a/src/components/shared/TcAlert.tsx b/src/components/shared/TcAlert.tsx new file mode 100644 index 00000000..edef6eaa --- /dev/null +++ b/src/components/shared/TcAlert.tsx @@ -0,0 +1,20 @@ +import { Alert, AlertProps } from '@mui/material'; +import React from 'react'; + +/** + * TcAlert Component + * + * A simple wrapper component for MUI's Alert component. It allows you to + * use MUI Alert with the flexibility to pass any valid AlertProps. + * + * @param {ITcAlertProps} props - The props for the TcAlert component. + * @returns {React.ReactElement} A React element representing the Alert component. + */ + +interface ITcAlertProps extends AlertProps {} + +function TcAlert({ ...props }: ITcAlertProps) { + return ; +} + +export default TcAlert; diff --git a/src/components/shared/TcAvatar.spec.tsx b/src/components/shared/TcAvatar.spec.tsx new file mode 100644 index 00000000..4213dada --- /dev/null +++ b/src/components/shared/TcAvatar.spec.tsx @@ -0,0 +1,16 @@ +// TcAvatar.test.tsx + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // For the "toBeInTheDocument" matcher +import TcAvatar from './TcAvatar'; // Adjust the path as needed + +describe('TcAvatar Component', () => { + it('renders the avatar with the provided src', () => { + const sampleSrc = 'https://example.com/sample-avatar.jpg'; + render(); + + const avatar = screen.getByRole('img'); + expect(avatar).toHaveAttribute('src', sampleSrc); + }); +}); diff --git a/src/components/shared/TcAvatar.tsx b/src/components/shared/TcAvatar.tsx new file mode 100644 index 00000000..d616f167 --- /dev/null +++ b/src/components/shared/TcAvatar.tsx @@ -0,0 +1,25 @@ +import { Avatar, AvatarProps } from '@mui/material'; +import React from 'react'; + +/** + * ITcAvatarProps interface. + * + * Represents the properties for the TcAvatar component. + * It extends AvatarProps from MUI's Avatar to inherit all of its properties. + */ +interface ITcAvatarProps extends AvatarProps {} + +/** + * `TcAvatar` functional component. + * + * This is a wrapper around MUI's Avatar component. + * It can accept all the props that MUI's Avatar component can. + * + * @param props - The properties the component accepts. See `ITcAvatarProps`. + * @returns A JSX element representing an Avatar with the provided properties. + */ +function TcAvatar({ ...props }: ITcAvatarProps) { + return ; +} + +export default TcAvatar; diff --git a/src/components/shared/TcBox/TcBoxContainer.spec.tsx b/src/components/shared/TcBox/TcBoxContainer.spec.tsx new file mode 100644 index 00000000..8fff8df9 --- /dev/null +++ b/src/components/shared/TcBox/TcBoxContainer.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcBoxContainer from './TcBoxContainer'; + +// Mock the child components +jest.mock('./TcBoxTitleContainer', () => (props: any) => ( +

{props.children}
+)); +jest.mock('./TcBoxContentContainer', () => (props: any) => ( +
{props.children}
+)); + +describe('', () => { + it('renders title and content children correctly', () => { + const titleChild = Title Child; + const contentChild = Content Child; + + const { getByTestId, getByText } = render( + + ); + + const titleContainer = getByTestId('mock-title-container'); + const contentContainer = getByTestId('mock-content-container'); + + expect(titleContainer).toContainElement(getByText('Title Child')); + expect(contentContainer).toContainElement(getByText('Content Child')); + }); +}); diff --git a/src/components/shared/TcBox/TcBoxContainer.tsx b/src/components/shared/TcBox/TcBoxContainer.tsx new file mode 100644 index 00000000..23840f2c --- /dev/null +++ b/src/components/shared/TcBox/TcBoxContainer.tsx @@ -0,0 +1,45 @@ +/** + * TcBoxContainer Component. + * + * This is a container component that combines a title and content area. + * + * Props: + * - titleContainerChildren: Element that will be rendered in the title container. + * - contentContainerChildren: Element that will be rendered in the content container. + * + * Example: + * ```jsx + * Title} + * contentContainerChildren={

Some content here.

} + * /> + * ``` + */ + +import { Box, BoxProps } from '@mui/material'; +import TcBoxTitleContainer from './TcBoxTitleContainer'; +import TcBoxContentContainer from './TcBoxContentContainer'; + +interface ITcBoxContainer extends Omit { + titleContainerChildren?: JSX.Element | React.ReactElement; + contentContainerChildren?: JSX.Element | React.ReactElement; +} + +function TcBoxContainer({ + titleContainerChildren, + contentContainerChildren, + ...props +}: ITcBoxContainer) { + return ( + + {titleContainerChildren && ( + + )} + {contentContainerChildren && ( + + )} + + ); +} + +export default TcBoxContainer; diff --git a/src/components/shared/TcBox/TcBoxContentContainer.spec.tsx b/src/components/shared/TcBox/TcBoxContentContainer.spec.tsx new file mode 100644 index 00000000..e1e74bc0 --- /dev/null +++ b/src/components/shared/TcBox/TcBoxContentContainer.spec.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcBoxContentContainer from './TcBoxContentContainer'; + +describe('', () => { + // Test: Renders children correctly + it('renders children correctly', () => { + const { getByText } = render( + Test Content} /> + ); + + expect(getByText('Test Content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcBox/TcBoxContentContainer.tsx b/src/components/shared/TcBox/TcBoxContentContainer.tsx new file mode 100644 index 00000000..e30d951d --- /dev/null +++ b/src/components/shared/TcBox/TcBoxContentContainer.tsx @@ -0,0 +1,27 @@ +/** + * TcBoxContentContainer Component. + * + * A generic container for content. + * + * Props: + * - children: The content that will be displayed inside this container. + * + * Example: + * ```jsx + * + *

This is some content inside the container.

+ *
+ * ``` + */ + +import React from 'react'; + +interface ITcBoxContentContainer { + children: JSX.Element | React.ReactElement; +} + +function TcBoxContentContainer({ children }: ITcBoxContentContainer) { + return
{children}
; +} + +export default TcBoxContentContainer; diff --git a/src/components/shared/TcBox/TcBoxTitleContainer.spec.tsx b/src/components/shared/TcBox/TcBoxTitleContainer.spec.tsx new file mode 100644 index 00000000..de089913 --- /dev/null +++ b/src/components/shared/TcBox/TcBoxTitleContainer.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcBoxTitleContainer from './TcBoxTitleContainer'; + +describe('', () => { + // Test 1: Renders children correctly + it('renders children correctly', () => { + const { getByText } = render( + Test Content} /> + ); + + expect(getByText('Test Content')).toBeInTheDocument(); + }); + + // Test 2: Applies custom classes + it('applies custom classes correctly', () => { + const { container } = render( + Test Content} + customClasses="test-class1 test-class2" + /> + ); + + const div = container.firstChild; + expect(div).toHaveClass('test-class1'); + expect(div).toHaveClass('test-class2'); + }); +}); diff --git a/src/components/shared/TcBox/TcBoxTitleContainer.tsx b/src/components/shared/TcBox/TcBoxTitleContainer.tsx new file mode 100644 index 00000000..e25677c5 --- /dev/null +++ b/src/components/shared/TcBox/TcBoxTitleContainer.tsx @@ -0,0 +1,33 @@ +/** + * TcBoxTitleContainer Component. + * + * A container dedicated for displaying titles. + * + * Props: + * - children: The content that needs to be displayed inside this container. + * - customClasses (optional): Additional classnames that can be added to the container for custom styling. + * + * Example: + * ```jsx + * + *

Title Goes Here

+ *
+ * ``` + */ + +import clsx from 'clsx'; +import React from 'react'; + +interface ITcBoxTitleContainer { + children: JSX.Element; + customClasses?: string; +} + +function TcBoxTitleContainer({ + children, + customClasses, +}: ITcBoxTitleContainer) { + return
{children}
; +} + +export default TcBoxTitleContainer; diff --git a/src/components/shared/TcBox/index.ts b/src/components/shared/TcBox/index.ts new file mode 100644 index 00000000..2a244a88 --- /dev/null +++ b/src/components/shared/TcBox/index.ts @@ -0,0 +1,3 @@ +import { default as TcBoxContainer } from './TcBoxContainer'; + +export default { TcBoxContainer }; diff --git a/src/components/shared/TcBreadcrumbs.spec.tsx b/src/components/shared/TcBreadcrumbs.spec.tsx new file mode 100644 index 00000000..30cce2a4 --- /dev/null +++ b/src/components/shared/TcBreadcrumbs.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcBreadcrumbs from './TcBreadcrumbs'; +import { useRouter } from 'next/router'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +describe('TcBreadcrumbs', () => { + const mockPush = jest.fn(); + + beforeEach(() => { + (useRouter as jest.Mock).mockReturnValue({ + push: mockPush, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders breadcrumb links based on the items prop', () => { + const items = [ + { label: 'Home', path: '/' }, + { label: 'About', path: '/about' }, + ]; + + render(); + + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('About')).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcBreadcrumbs.tsx b/src/components/shared/TcBreadcrumbs.tsx new file mode 100644 index 00000000..465b792b --- /dev/null +++ b/src/components/shared/TcBreadcrumbs.tsx @@ -0,0 +1,74 @@ +/** + * `TcBreadcrumbs` Component + * + * This component is used for displaying a breadcrumb navigation interface. It is built + * using Material-UI's `Breadcrumbs` and a custom `TcLink` component for navigation. + * + * Props: + * - `items`: An array of `BreadcrumbItem` objects. Each `BreadcrumbItem` should have: + * - `label` (string): The text displayed for the breadcrumb link. + * - `path` (string): The navigation path the breadcrumb link points to. + * + * Usage: + * + * + * This component renders breadcrumbs for the provided `items` array. Each item in the array + * represents a single breadcrumb link. The component uses flexbox for alignment and spacing, + * and includes a hover effect on the links for better user interaction. + */ + +import React from 'react'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import { useRouter } from 'next/router'; +import TcLink from './TcLink'; +import { MdOutlineKeyboardArrowLeft } from 'react-icons/md'; + +interface BreadcrumbItem { + label: string; + path: string; +} + +interface TcBreadcrumbsProps { + items: BreadcrumbItem[]; +} + +function TcBreadcrumbs({ items }: TcBreadcrumbsProps) { + const router = useRouter(); + + const handleClick = ( + event: React.MouseEvent, + path: string + ) => { + event.preventDefault(); + router.push(path); + }; + + return ( + + {items.map((item) => ( +
+ + handleClick(event, item.path)} + > + {item.label} + +
+ ))} +
+ ); +} + +export default TcBreadcrumbs; diff --git a/src/components/shared/TcButton.spec.tsx b/src/components/shared/TcButton.spec.tsx new file mode 100644 index 00000000..39c7a62f --- /dev/null +++ b/src/components/shared/TcButton.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // for the "toBeInTheDocument" matcher +import TcButton from './TcButton'; + +describe('', () => { + test('renders contained button with TcText wrapping', () => { + render(); + const buttonElement = screen.getByText('Sample Text'); + expect(buttonElement).toBeInTheDocument(); + }); + + test('renders outlined button with TcText wrapping', () => { + render(); + const buttonElement = screen.getByText('Sample Text'); + expect(buttonElement).toBeInTheDocument(); + }); + + test('renders button without variant with direct text', () => { + render(); + const buttonElement = screen.getByText('Sample Text'); + expect(buttonElement).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcButton.tsx b/src/components/shared/TcButton.tsx new file mode 100644 index 00000000..77cacca4 --- /dev/null +++ b/src/components/shared/TcButton.tsx @@ -0,0 +1,38 @@ +/** + * `TcButton` functional component. + * + * This component is an enhanced version of Material-UI's Button component. Depending on the `variant` prop, + * it customizes the appearance of the button's content. If the variant is either 'contained' or 'outlined', + * it wraps the text inside the `TcText` component. Otherwise, it simply displays the text. + * + * @param {ITcButtonProps} props - Properties passed to the component. + * @returns {React.ReactElement} Rendered button component. + */ + +import { Button, ButtonProps } from '@mui/material'; +import React from 'react'; +import TcText from './TcText'; + +interface ITcButtonProps extends ButtonProps { + text: string; +} + +function TcButton({ text, ...props }: ITcButtonProps) { + if (props.variant === 'contained') { + return ( + + ); + } + if (props.variant === 'outlined') { + return ( + + ); + } + return ; +} + +export default TcButton; diff --git a/src/components/shared/TcButtonGroup/TcButtonGroup.tsx b/src/components/shared/TcButtonGroup/TcButtonGroup.tsx new file mode 100644 index 00000000..a6433c31 --- /dev/null +++ b/src/components/shared/TcButtonGroup/TcButtonGroup.tsx @@ -0,0 +1,12 @@ +import { ButtonGroup, ButtonGroupProps } from '@mui/material'; +import React, { ReactElement, ReactNode } from 'react'; + +interface TcButtonGroup extends ButtonGroupProps { + children: ReactNode; +} + +function TcButtonGroup({ children, ...props }: TcButtonGroup) { + return {children}; +} + +export default TcButtonGroup; diff --git a/src/components/shared/TcButtonGroup/index.ts b/src/components/shared/TcButtonGroup/index.ts new file mode 100644 index 00000000..16673dda --- /dev/null +++ b/src/components/shared/TcButtonGroup/index.ts @@ -0,0 +1,3 @@ +import { default as TcButtonGroup } from './TcButtonGroup'; + +export default TcButtonGroup; diff --git a/src/components/shared/TcCard.spec.tsx b/src/components/shared/TcCard.spec.tsx new file mode 100644 index 00000000..b1762603 --- /dev/null +++ b/src/components/shared/TcCard.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcCard from './TcCard'; +import { Typography, CardContent } from '@mui/material'; + +describe('', () => { + // Test 1: Check if the component renders correctly + it('renders without crashing', () => { + render(test} />); + }); + + // Test 2: Check if the component renders its children + it('renders its children', () => { + const { getByText } = render( + + + Card Content + + + ); + + expect(getByText('Card Content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcCard.tsx b/src/components/shared/TcCard.tsx new file mode 100644 index 00000000..e5a66d94 --- /dev/null +++ b/src/components/shared/TcCard.tsx @@ -0,0 +1,33 @@ +/** + * TcCard Component. + * + * A wrapper around the Material-UI Card component with the added constraint that it must contain children. + * This component extends all properties of the MUI Card, making it versatile for various use cases. + * + * Props: + * - children: The content to be displayed inside the card. It's a mandatory prop for TcCard. + * - ...props: All other properties supported by the MUI Card component. Refer to MUI documentation for a complete list. + * + * Example: + * ```jsx + * + * + * Card Title + * Card content goes here. + * + * + * ``` + */ + +import { Card, CardProps } from '@mui/material'; +import React from 'react'; + +interface ITcCard extends CardProps { + children: JSX.Element | React.ReactElement; +} + +function TcCard({ children, ...props }: ITcCard) { + return {children}; +} + +export default TcCard; diff --git a/src/components/shared/TcCheckbox.spec.tsx b/src/components/shared/TcCheckbox.spec.tsx new file mode 100644 index 00000000..d57d6d30 --- /dev/null +++ b/src/components/shared/TcCheckbox.spec.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // For the "toBeInTheDocument" matcher and other extended matchers + +import TcCheckbox from './TcCheckbox'; + +describe('TcCheckbox', () => { + it('renders without crashing', () => { + render(); + }); +}); diff --git a/src/components/shared/TcCheckbox.tsx b/src/components/shared/TcCheckbox.tsx new file mode 100644 index 00000000..88f26005 --- /dev/null +++ b/src/components/shared/TcCheckbox.tsx @@ -0,0 +1,39 @@ +/** + * `TcCheckbox` Component + * + * This component is a simple wrapper around MUI's Checkbox component. + * It provides an encapsulated way to manage and use checkboxes, while + * offering all the extended properties and behaviors of the underlying MUI Checkbox. + * + * Props: + * All properties of the original MUI Checkbox component are supported. Refer to + * MUI documentation for detailed prop types and descriptions. + * @see https://mui.com/api/checkbox/ + * + * Usage: + * ```jsx + * + * ``` + * + * Note: + * - Ensure that you manage the checked state externally when using this component. + * - Bind an onChange handler to capture and manage checkbox state changes. + * + * @param {ITcCheckboxProps} props - Extended MUI Checkbox properties. + * @returns {JSX.Element} Rendered Checkbox component. + */ + +import React from 'react'; +import Checkbox, { CheckboxProps } from '@mui/material/Checkbox'; + +interface ITcCheckboxProps extends CheckboxProps {} + +function TcCheckbox({ ...props }: ITcCheckboxProps) { + return ; +} + +export default TcCheckbox; diff --git a/src/components/shared/TcCollapse.spec.tsx b/src/components/shared/TcCollapse.spec.tsx new file mode 100644 index 00000000..1444bd10 --- /dev/null +++ b/src/components/shared/TcCollapse.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcCollapse from './TcCollapse'; // Replace with the correct import path + +describe('TcCollapse Component', () => { + it('renders children content when open', () => { + const { getByText } = render( + +
Content inside Collapse
+
+ ); + + const content = getByText('Content inside Collapse'); + + // Check if the content is visible when open + expect(content).toBeVisible(); + }); +}); diff --git a/src/components/shared/TcCollapse.tsx b/src/components/shared/TcCollapse.tsx new file mode 100644 index 00000000..15778bd5 --- /dev/null +++ b/src/components/shared/TcCollapse.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import Collapse, { CollapseProps } from '@mui/material/Collapse'; + +/** + * Custom wrapper component for MUI's Collapse component. + * + * @param {ITcCollapseProps} props - The props for the TcCollapse component. + * @returns {React.ReactElement} A React element representing the Collapse component. + */ + +/** + * Interface for the props of the TcCollapse component, extending CollapseProps. + * + * @interface + */ +interface ITcCollapseProps extends CollapseProps { + /** + * The children prop represents the content to be displayed inside the Collapse component. + * + * @type {React.ReactElement | JSX.Element} + */ + children: React.ReactElement | JSX.Element; +} + +function TcCollapse({ children, ...props }: ITcCollapseProps) { + return {children}; +} + +export default TcCollapse; diff --git a/src/components/shared/TcDialog/TcDialog.spec.tsx b/src/components/shared/TcDialog/TcDialog.spec.tsx new file mode 100644 index 00000000..d432f10a --- /dev/null +++ b/src/components/shared/TcDialog/TcDialog.spec.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcDialog from './TcDialog'; + +describe('TcDialog Component', () => { + it('renders the dialog with children content', () => { + const { getByText } = render( + +
Dialog Content
+
+ ); + + const content = getByText('Dialog Content'); + expect(content).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcDialog/TcDialog.tsx b/src/components/shared/TcDialog/TcDialog.tsx new file mode 100644 index 00000000..a44a9d85 --- /dev/null +++ b/src/components/shared/TcDialog/TcDialog.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Dialog, DialogProps } from '@mui/material'; + +/** + * `TcDialog` Component + * + * A custom wrapper component for Material-UI's Dialog component, allowing you + * to easily control the visibility of the dialog using the `open` prop. + * + * @param {ITcDialogProps} props - Extended Dialog properties. + * @returns {React.ReactElement} A React element representing the Dialog component. + */ +interface ITcDialogProps extends DialogProps { + /** + * The children prop represents the content to be displayed inside the Dialog component. + * + * @type {React.ReactNode } + */ + children: React.ReactNode; +} + +function TcDialog({ children, ...props }: ITcDialogProps) { + return {children}; +} + +export default TcDialog; diff --git a/src/components/shared/TcDialog/index.ts b/src/components/shared/TcDialog/index.ts new file mode 100644 index 00000000..474d824d --- /dev/null +++ b/src/components/shared/TcDialog/index.ts @@ -0,0 +1,3 @@ +import { default as TcDialog } from './TcDialog'; + +export default TcDialog; diff --git a/src/components/shared/TcIconWithTooltip.spec.tsx b/src/components/shared/TcIconWithTooltip.spec.tsx new file mode 100644 index 00000000..cf0f9705 --- /dev/null +++ b/src/components/shared/TcIconWithTooltip.spec.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcIconWithTooltip from './TcIconWithTooltip'; + +describe('', () => { + // Test 1: Check if the component renders correctly + it('renders without crashing', () => { + render(); + }); + + // Test 2: Check if the default icon (SVG) is rendered if none is provided + it('renders the default SVG icon if none is provided', () => { + render(); + const svgIcon = screen.getByTestId('icon-svg'); // Make sure you add this test-id to your SVG in the component + expect(svgIcon).toBeInTheDocument(); + }); + + // Test 3: Check if a custom icon is rendered when provided + it('renders a custom icon (SVG) when provided', () => { + const customIcon = ; + render( + + ); + const renderedIcon = screen.getByTestId('custom-icon'); + expect(renderedIcon).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcIconWithTooltip.tsx b/src/components/shared/TcIconWithTooltip.tsx new file mode 100644 index 00000000..be6d2e27 --- /dev/null +++ b/src/components/shared/TcIconWithTooltip.tsx @@ -0,0 +1,41 @@ +/** + * TcIconWithTooltip Component. + * + * This component displays an icon wrapped with a Material-UI Tooltip. When the icon is hovered over, + * a tooltip is displayed above the icon with the provided description. + * + * Props: + * - iconComponent: The icon that will be displayed. By default, it uses `MdOutlineInfo` from `react-icons/md`. + * - tooltipText: The text content that will be displayed inside the tooltip. + * + * Example: + * ```jsx + * + * } tooltipText="This is a help icon" /> + * ``` + */ + +import { Tooltip } from '@mui/material'; +import React from 'react'; +import { MdOutlineInfo } from 'react-icons/md'; + +interface ITcIconWithTooltip { + iconComponent?: React.ReactElement | JSX.Element; + tooltipText: string; +} + +function TcIconWithTooltip({ iconComponent, tooltipText }: ITcIconWithTooltip) { + return ( + +
{iconComponent}
+
+ ); +} + +TcIconWithTooltip.defaultProps = { + iconComponent: ( + + ), +}; + +export default TcIconWithTooltip; diff --git a/src/components/shared/TcInput.spec.tsx b/src/components/shared/TcInput.spec.tsx new file mode 100644 index 00000000..8daef671 --- /dev/null +++ b/src/components/shared/TcInput.spec.tsx @@ -0,0 +1,15 @@ +// TcInput.test.tsx + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // For the "toBeInTheDocument" matcher +import TcInput from './TcInput'; // Adjust the path as needed + +describe('TcInput Component', () => { + it('renders the provided label', () => { + const label = 'Test Label'; + render(); + + expect(screen.getByLabelText(label)).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcInput.tsx b/src/components/shared/TcInput.tsx new file mode 100644 index 00000000..3e7b8cd4 --- /dev/null +++ b/src/components/shared/TcInput.tsx @@ -0,0 +1,42 @@ +import { + StandardTextFieldProps, + FilledTextFieldProps, + OutlinedTextFieldProps, + TextField, +} from '@mui/material'; + +/** + * Represents custom properties specific to the TcInput component. + */ +interface ICustomProps { + /** Label for the input */ + label: string; +} + +/** + * Represents the properties of the TcInput component. + * It combines custom properties with any of the three variants + * of TextField properties provided by MUI. + */ +type TcInputProps = ICustomProps & + (StandardTextFieldProps | FilledTextFieldProps | OutlinedTextFieldProps); + +/** + * `TcInput` is a functional component that serves as a wrapper around MUI's TextField. + * It ensures that a label prop is always provided along with any other properties + * that a regular MUI TextField would accept. + * + * @param props - The properties the component accepts. See `TcInputProps`. + * @returns A JSX element representing a TextField with the provided properties. + */ + +const TcInput = ({ InputProps, ...props }: TcInputProps) => { + const mergedInputProps = { + ...InputProps, + sx: { ...(InputProps?.sx || {}), borderRadius: 0 }, + }; + + return ; +}; + +export default TcInput; diff --git a/src/components/shared/TcLink.spec.tsx b/src/components/shared/TcLink.spec.tsx new file mode 100644 index 00000000..80da82ef --- /dev/null +++ b/src/components/shared/TcLink.spec.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcLink from './TcLink'; + +describe('TcLink component', () => { + test('Render component correctly', () => { + const defaultText = 'Title Test'; + + //arrange + const { getByText } = render({defaultText}); + + //assert + expect(getByText(defaultText)).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcLink.tsx b/src/components/shared/TcLink.tsx new file mode 100644 index 00000000..192004b5 --- /dev/null +++ b/src/components/shared/TcLink.tsx @@ -0,0 +1,36 @@ +/** + * TcLink Component + * + * This component serves as a wrapper around the MUI's Link component with custom properties. + * It's designed to provide a standardized link appearance and behavior throughout the application. + * + * @component + * + * @param {object} props - The properties object. + * @param {ReactNode} props.children - The content of the link (e.g., text, icons). + * @param {string} props.to - The URL that the link should point to. + * @param {...MuiLinkProps} props - The props of the properties that can be passed to the MUI Link component. + * + * @returns {ReactElement} The TcLink component. + * + * @example + * // Usage example: + * Visit Example + */ + +import React from 'react'; +import { Link, LinkProps as MuiLinkProps } from '@mui/material'; + +interface CustomLinkProps extends MuiLinkProps { + to: string; +} + +function TcLink({ children, to, ...props }: CustomLinkProps) { + return ( + + {children} + + ); +} + +export default TcLink; diff --git a/src/components/shared/TcPopover/TcPopover.spec.tsx b/src/components/shared/TcPopover/TcPopover.spec.tsx new file mode 100644 index 00000000..1e90033e --- /dev/null +++ b/src/components/shared/TcPopover/TcPopover.spec.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcPopover from './TcPopover'; + +describe('TcPopover', () => { + test('displays content when open', () => { + const content =
Test Content
; + render( + {}} + /> + ); + + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcPopover/TcPopover.tsx b/src/components/shared/TcPopover/TcPopover.tsx new file mode 100644 index 00000000..ea3c93b3 --- /dev/null +++ b/src/components/shared/TcPopover/TcPopover.tsx @@ -0,0 +1,55 @@ +/** + * TcPopover Component + * + * A reusable popover component that displays content in a Material-UI Popover. + * It can be controlled (opened and closed) programmatically. + * + * Props: + * - open: Boolean indicating if the popover is open. + * - anchorEl: The DOM element used as the popover's anchor. + * - content: The content to be displayed inside the popover. + * - onClose: Callback function to be called when the popover is requested to be closed. + * + * Example Usage: + * Popover content
} + * onClose={() => setIsOpen(false)} + * /> + */ + +import React from 'react'; +import { Popover, PopoverProps } from '@mui/material'; + +interface TcPopoverProps extends PopoverProps { + open: boolean; + anchorEl: HTMLButtonElement | null; + content: React.ReactNode; + onClose: () => void; +} + +const TcPopover = ({ + open, + anchorEl, + content, + onClose, + ...props +}: TcPopoverProps) => { + return ( + + {content} + + ); +}; + +export default TcPopover; diff --git a/src/components/shared/TcPopover/index.ts b/src/components/shared/TcPopover/index.ts new file mode 100644 index 00000000..ec7b9ea1 --- /dev/null +++ b/src/components/shared/TcPopover/index.ts @@ -0,0 +1,3 @@ +import { default as TcPopover } from './TcPopover'; + +export default TcPopover; diff --git a/src/components/shared/TcSelect/TcSelect.tsx b/src/components/shared/TcSelect/TcSelect.tsx new file mode 100644 index 00000000..d911cd2d --- /dev/null +++ b/src/components/shared/TcSelect/TcSelect.tsx @@ -0,0 +1,49 @@ +/** + * TcSelect Component + * + * This component is a wrapper around Material-UI's Select component. + * It provides a dropdown select box functionality. + * + * Props: + * - options: Array of objects with 'value' and 'label' keys. These are used to populate the dropdown menu. + * + * Example Usage: + * + */ + +import React, { useState } from 'react'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; + +interface TcSelectProps { + options: { + value: string; + label: string; + }[]; +} + +function TcSelect({ options, ...props }: TcSelectProps) { + const [value, setValue] = useState(''); + + const handleChange = (event: SelectChangeEvent) => { + const newValue = event.target.value as string; + setValue(newValue); + }; + + return ( + + ); +} + +export default TcSelect; diff --git a/src/components/shared/TcSelect/TsSelect.spec.tsx b/src/components/shared/TcSelect/TsSelect.spec.tsx new file mode 100644 index 00000000..5313e833 --- /dev/null +++ b/src/components/shared/TcSelect/TsSelect.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TcSelect from './TcSelect'; + +describe('TcSelect Component', () => { + const mockOptions = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + ]; + + test('renders the select component', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + test('can select an option', () => { + render(); + fireEvent.mouseDown(screen.getByRole('button')); + const option = screen.getByText('Option 1'); + fireEvent.click(option); + expect(screen.getByRole('button').textContent).toBe('Option 1'); + }); +}); diff --git a/src/components/shared/TcSelect/index.ts b/src/components/shared/TcSelect/index.ts new file mode 100644 index 00000000..852f4e1f --- /dev/null +++ b/src/components/shared/TcSelect/index.ts @@ -0,0 +1,3 @@ +import { default as TcSelect } from './TcSelect'; + +export default TcSelect; diff --git a/src/components/shared/TcText.spec.tsx b/src/components/shared/TcText.spec.tsx new file mode 100644 index 00000000..b5b45d20 --- /dev/null +++ b/src/components/shared/TcText.spec.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcTitle from './TcText'; + +describe('TcTitle component', () => { + test('Render component correctly', () => { + const defaultText = 'Title Test'; + //arrange + const { getByText } = render(); + //act + + //assert + expect(getByText(defaultText)).toBeInTheDocument(); + }); +}); diff --git a/src/components/shared/TcText.tsx b/src/components/shared/TcText.tsx new file mode 100644 index 00000000..901a68fc --- /dev/null +++ b/src/components/shared/TcText.tsx @@ -0,0 +1,39 @@ +import { Typography, TypographyProps } from '@mui/material'; +import React from 'react'; + +/** + * TcTitle Component + * + * Description: + * The `TcTitle` component is a wrapper around MUI's `Typography` component + * with specific restrictions on the typography variants that can be utilized. + * + * Props: + * - `title`: (Required) A string that represents the text content to be rendered. + * + * All other props available to MUI's `Typography` component can also be + * passed to `TcTitle` except for the `variant` prop which is strictly typed + * to the above variants. + * + * Usage: + * ```jsx + * + * ``` + * + * Note: + * This component is specifically designed to restrict the use of typography + * variants to maintain a consistent font-size throughout our application. + * Make sure to only use the allowed variants listed above. Any other variant + * from MUI's `Typography` component is not permitted with `TcTitle`. + * + */ + +interface ITcTextProps extends TypographyProps { + text: string | number | React.ReactNode | JSX.Element; +} + +function TcText({ text, ...props }: ITcTextProps) { + return {text}; +} + +export default TcText; diff --git a/src/components/twitter/growth/accountActivity/TcAccountActivity.spec.tsx b/src/components/twitter/growth/accountActivity/TcAccountActivity.spec.tsx new file mode 100644 index 00000000..afabbc4c --- /dev/null +++ b/src/components/twitter/growth/accountActivity/TcAccountActivity.spec.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TcAccountActivity from './TcAccountActivity'; +import TcAccountActivityHeader from './TcAccountActivityHeader'; +import TcAccountActivityContent from './TcAccountActivityContent'; +import { unmountComponentAtNode } from 'react-dom'; + +// Mocking the child components to check only if they're rendered +jest.mock('./TcAccountActivityHeader', () => { + return { + __esModule: true, + default: jest.fn(() =>
), + }; +}); + +jest.mock('./TcAccountActivityContent', () => { + return { + __esModule: true, + default: jest.fn(() =>
), + }; +}); + +describe('', () => { + let container: any = null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('renders without crashing', () => { + render(, container); + }); + + it('renders TcAccountActivityHeader component', () => { + const { getByTestId } = render(, container); + expect(getByTestId('header-mock')).toBeInTheDocument(); + }); + + it('renders TcAccountActivityContent component', () => { + const { getByTestId } = render(, container); + expect(getByTestId('content-mock')).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/accountActivity/TcAccountActivity.tsx b/src/components/twitter/growth/accountActivity/TcAccountActivity.tsx new file mode 100644 index 00000000..f69bc602 --- /dev/null +++ b/src/components/twitter/growth/accountActivity/TcAccountActivity.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import TcAccountActivityHeader from './TcAccountActivityHeader'; +import TcAccountActivityContent from './TcAccountActivityContent'; +import { IAccount } from '../../../../utils/interfaces'; + +interface ITcAccountActivityProps { + account: IAccount; +} + +interface IAccountItems { + description: string; + value: number; + hasTooltipInfo: boolean; +} + +function TcAccountActivity({ account }: ITcAccountActivityProps) { + const [activityAccount, setActivityAccount] = useState([ + { + description: 'Accounts that engage with you', + value: 0, + hasTooltipInfo: true, + }, + { + description: 'Your followers', + value: 0, + hasTooltipInfo: false, + }, + ]); + + useEffect(() => { + if (account) { + const updatedAccountActivity = [ + { + description: 'Accounts that engage with you', + value: account.engagement, + hasTooltipInfo: true, + }, + { + description: 'Your followers', + value: account.follower, + hasTooltipInfo: false, + }, + ]; + + setActivityAccount(updatedAccountActivity); + } + }, [account]); + + return ( + <> + + + + ); +} + +export default TcAccountActivity; diff --git a/src/components/twitter/growth/accountActivity/TcAccountActivityContent.spec.tsx b/src/components/twitter/growth/accountActivity/TcAccountActivityContent.spec.tsx new file mode 100644 index 00000000..58d802da --- /dev/null +++ b/src/components/twitter/growth/accountActivity/TcAccountActivityContent.spec.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcAccountActivityContent from './TcAccountActivityContent'; + +describe('', () => { + // Test 1: Check if the component renders correctly + it('renders without crashing', () => { + render( + + ); + }); + + it('renders the correct data in cards', () => { + render( + + ); + + expect(screen.getByText('10')).toBeInTheDocument(); + expect( + screen.getByText('Accounts that engage with you') + ).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); + expect(screen.getByText('Your followers')).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/accountActivity/TcAccountActivityContent.tsx b/src/components/twitter/growth/accountActivity/TcAccountActivityContent.tsx new file mode 100644 index 00000000..bd9501ac --- /dev/null +++ b/src/components/twitter/growth/accountActivity/TcAccountActivityContent.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import TcCard from '../../../shared/TcCard'; +import TcText from '../../../shared/TcText'; +import TcIconWithTooltip from '../../../shared/TcIconWithTooltip'; + +interface ITcAccountActivityContentProps { + activityList: { + description: string; + value: number; + hasTooltipInfo: boolean; + }[]; +} + +function TcAccountActivityContent({ + activityList, +}: ITcAccountActivityContentProps) { + return ( +
+ {activityList && + activityList.map((el, index) => ( + + + +
+ {el.hasTooltipInfo ? ( + + ) : ( + '' + )} +
+
+ } + /> + ))} +
+ ); +} + +export default TcAccountActivityContent; diff --git a/src/components/twitter/growth/accountActivity/TcAccountActivityHeader.spec.tsx b/src/components/twitter/growth/accountActivity/TcAccountActivityHeader.spec.tsx new file mode 100644 index 00000000..f2258748 --- /dev/null +++ b/src/components/twitter/growth/accountActivity/TcAccountActivityHeader.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { StorageService } from '../../../../services/StorageService'; +import TcAccountActivityHeader from './TcAccountActivityHeader'; + +jest.mock('../../../../services/StorageService'); + +describe('', () => { + beforeEach(() => { + // Mocking the `readLocalStorage` method + const mockedReadLocalStorage = + StorageService.readLocalStorage as jest.MockedFunction< + typeof StorageService.readLocalStorage + >; + + mockedReadLocalStorage.mockReturnValue({ + twitter: { + twitterUsername: 'testUser', + }, + }); + + render(); + }); + + it('renders the main header text', () => { + const headerText = screen.getByText('Account activity'); + expect(headerText).toBeInTheDocument(); + }); + + it('renders the time information with an icon', () => { + const timeInfoText = screen.getByText('Data over the last 7 days'); + const timeIcon = screen.getByTestId('bi-time-five-icon'); + expect(timeInfoText).toBeInTheDocument(); + expect(timeIcon).toBeInTheDocument(); + }); + + it('renders the analyzed account username when provided', () => { + const usernameLink = screen.getByText('@testUser'); + expect(usernameLink).toBeInTheDocument(); + expect(usernameLink).toHaveAttribute('href', '/settings'); + }); +}); diff --git a/src/components/twitter/growth/accountActivity/TcAccountActivityHeader.tsx b/src/components/twitter/growth/accountActivity/TcAccountActivityHeader.tsx new file mode 100644 index 00000000..8fa14290 --- /dev/null +++ b/src/components/twitter/growth/accountActivity/TcAccountActivityHeader.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { BiTimeFive } from 'react-icons/bi'; +import TcText from '../../../shared/TcText'; +import TcLink from '../../../shared/TcLink'; +import { StorageService } from '../../../../services/StorageService'; +import { IUser } from '../../../../utils/types'; + +export default function TcAccountActivityHeader() { + const user = StorageService.readLocalStorage('user'); + return ( +
+
+ +
+ + +
+
+
+ + {user?.twitter?.twitterUsername ? ( + <> + + @{user?.twitter?.twitterUsername} + + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/twitter/growth/accountActivity/index.ts b/src/components/twitter/growth/accountActivity/index.ts new file mode 100644 index 00000000..1390b1f9 --- /dev/null +++ b/src/components/twitter/growth/accountActivity/index.ts @@ -0,0 +1 @@ +import { default as TcAccountActivity } from './TcAccountActivity'; diff --git a/src/components/twitter/growth/audienceResponse/TcAudienceResponse.spec.tsx b/src/components/twitter/growth/audienceResponse/TcAudienceResponse.spec.tsx new file mode 100644 index 00000000..72349c42 --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/TcAudienceResponse.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcAudienceResponse from './TcAudienceResponse'; + +describe('', () => { + const mockAudience = { + replies: 50, + retweets: 30, + mentions: 20, + posts: 0, + likes: 0, + }; + + beforeEach(() => { + render(); + }); + + // Test 1: Check if the "Audience response" header text from `TcAudienceResponseHeader` is rendered + it('renders the header text correctly', () => { + const headerText = screen.getByText('Audience response'); + expect(headerText).toBeInTheDocument(); + }); + + // Test 2: Check if any one of the descriptions from `TcAudienceResponseContent` is rendered + it('renders the content descriptions correctly based on provided audience data', () => { + // Using the data in mockAudience for validation + const repliesDescription = screen.getByText('Replies'); + const retweetsDescription = screen.getByText('Retweets'); + const mentionsDescription = screen.getByText('Mentions'); + + expect(repliesDescription).toBeInTheDocument(); + expect(retweetsDescription).toBeInTheDocument(); + expect(mentionsDescription).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/audienceResponse/TcAudienceResponse.tsx b/src/components/twitter/growth/audienceResponse/TcAudienceResponse.tsx new file mode 100644 index 00000000..e8b62a31 --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/TcAudienceResponse.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useState } from 'react'; +import TcAudienceResponseHeader from './TcAudienceResponseHeader'; +import TcAudienceResponseContent from './TcAudienceResponseContent'; +import { capitalizeFirstChar } from '../../../../helpers/helper'; +import { IAudience } from '../../../../utils/interfaces'; +interface ITcAudienceResponseProps { + audience: IAudience; +} + +interface IAccountAudienceItem { + description: string; + value: number; + hasTooltipInfo: boolean; +} + +function TcAudienceResponse({ audience }: ITcAudienceResponseProps) { + const [audienceResponseList, setAudienceResponseList] = useState< + IAccountAudienceItem[] + >([]); + + useEffect(() => { + if (audience) { + const newState = Object.keys(audience).map((key) => { + const audienceKey = key as keyof IAudience; + return { + description: capitalizeFirstChar(audienceKey), + value: audience[audienceKey], + hasTooltipInfo: false, + }; + }); + setAudienceResponseList(newState); + } + }, [audience]); + + return ( +
+ + +
+ ); +} + +export default TcAudienceResponse; diff --git a/src/components/twitter/growth/audienceResponse/TcAudienceResponseContent.spec.tsx b/src/components/twitter/growth/audienceResponse/TcAudienceResponseContent.spec.tsx new file mode 100644 index 00000000..e96d5057 --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/TcAudienceResponseContent.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcAudienceResponseContent from './TcAudienceResponseContent'; + +describe('', () => { + const mockData = [ + { description: 'Replies', value: 50, hasTooltipInfo: false }, + { description: 'Retweets', value: 30, hasTooltipInfo: false }, + { description: 'Likes', value: 25, hasTooltipInfo: false }, + { description: 'Mentions', value: 20, hasTooltipInfo: false }, + ]; + + beforeEach(() => { + render(); + }); + + // Test 1: Check if the correct number of cards are rendered + it('renders the correct number of cards', () => { + const cards = screen.getAllByText(/Replies|Retweets|Likes|Mentions/); + expect(cards.length).toBe(4); + }); + + // Test 2: Check if the TcIconWithTooltip is not present for all cards + it('does not render any tooltips', () => { + const tooltipText = 'Followers and non-followers'; + expect(screen.queryByText(tooltipText)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/audienceResponse/TcAudienceResponseContent.tsx b/src/components/twitter/growth/audienceResponse/TcAudienceResponseContent.tsx new file mode 100644 index 00000000..99874e14 --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/TcAudienceResponseContent.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import TcCard from '../../../shared/TcCard'; +import TcIconWithTooltip from '../../../shared/TcIconWithTooltip'; +import TcText from '../../../shared/TcText'; + +interface IAccountAudienceItem { + description: string; + value: number; + hasTooltipInfo: boolean; +} + +interface ITcAudienceResponseContentProps { + data: IAccountAudienceItem[]; +} + +function TcAudienceResponseContent({ data }: ITcAudienceResponseContentProps) { + return ( +
+
+ {data && + data.map((el, index) => ( + + + +
+ {el.hasTooltipInfo ? ( + + ) : ( + '' + )} +
+
+ } + /> + ))} +
+
+ ); +} + +export default TcAudienceResponseContent; diff --git a/src/components/twitter/growth/audienceResponse/TcAudienceResponseHeader.spec.tsx b/src/components/twitter/growth/audienceResponse/TcAudienceResponseHeader.spec.tsx new file mode 100644 index 00000000..fb051ba3 --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/TcAudienceResponseHeader.spec.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcAudienceResponseHeader from './TcAudienceResponseHeader'; + +describe('', () => { + beforeEach(() => { + render(); + }); + + // Test 1: Check if the header text "Audience response" is rendered + it('renders the header text correctly', () => { + const headerText = screen.getByText('Audience response'); + expect(headerText).toBeInTheDocument(); + }); + + // Test 2: Check if the caption text "How much others react to your activities" is rendered + it('renders the caption text correctly', () => { + const captionText = screen.getByText( + 'How much others react to your activities' + ); + expect(captionText).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/audienceResponse/TcAudienceResponseHeader.tsx b/src/components/twitter/growth/audienceResponse/TcAudienceResponseHeader.tsx new file mode 100644 index 00000000..ca777dc4 --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/TcAudienceResponseHeader.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import TcText from '../../../shared/TcText'; + +function TcAudienceResponseHeader() { + return ( +
+ + +
+ ); +} + +export default TcAudienceResponseHeader; diff --git a/src/components/twitter/growth/audienceResponse/index.ts b/src/components/twitter/growth/audienceResponse/index.ts new file mode 100644 index 00000000..8caef6bd --- /dev/null +++ b/src/components/twitter/growth/audienceResponse/index.ts @@ -0,0 +1 @@ +import { default as TcYourAccountActivity } from './TcAudienceResponse'; diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccountContentItems.spec.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountContentItems.spec.tsx new file mode 100644 index 00000000..0cc1eb9a --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountContentItems.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import TcEngagementAccountContentItems from './TcEngagementAccountContentItems'; + +describe('TcEngagementAccountContentItems', () => { + const defaultProps = { + bgColor: 'bg-[#3A9E2B]', + value: '5', + description: 'Sample description', + tooltipText: 'Sample tooltip text', + }; + + it('renders the component and checks presence of value and description', () => { + render(); + + expect(screen.getByText(defaultProps.value.toString())).toBeInTheDocument(); + expect(screen.getByText(defaultProps.description)).toBeInTheDocument(); + }); + + it('renders the tooltip icon when tooltipText prop is provided', () => { + render(); + + expect(screen.getByTestId('icon-svg')).toBeInTheDocument(); + }); + + it('does not render the tooltip icon when tooltipText prop is not provided', () => { + const propsWithoutTooltip = { + ...defaultProps, + tooltipText: undefined, + }; + render(); + + expect(screen.queryByTestId('icon-svg')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccountContentItems.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountContentItems.tsx new file mode 100644 index 00000000..12de32e6 --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountContentItems.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; +import React from 'react'; +import TcText from '../../../shared/TcText'; +import TcIconWithTooltip from '../../../shared/TcIconWithTooltip'; +import { MdOutlineInfo } from 'react-icons/md'; + +interface ITcEngagementAccountContentItemsProps { + value: string | number; + description: string; + tooltipText?: string; + bgColor?: string; +} + +function TcEngagementAccountContentItems({ + bgColor, + value, + description, + tooltipText, +}: ITcEngagementAccountContentItemsProps) { + return ( +
+ + +
+ {tooltipText && ( + + } + /> + )} +
+
+ ); +} + +export default TcEngagementAccountContentItems; diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccounts.spec.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccounts.spec.tsx new file mode 100644 index 00000000..a1e736e3 --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccounts.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcEngagementAccounts from './TcEngagementAccounts'; +import TcEngagementAccountsHeader from './TcEngagementAccountsHeader'; + +describe('', () => { + const mockEngagement = { + hqla: 10, + hqhe: 20, + lqla: 15, + lqhe: 25, + }; + + beforeEach(() => { + render(); + }); + + // Test 1: Check if the TcEngagementAccountsHeader component is rendered. + it('renders the header component', () => { + render(); + }); + + // Test 2: Check if the data is rendered correctly in the TcEngagementAccountsContent component. + it('renders the correct engagement values and descriptions', () => { + const descriptions = [ + 'Only engaged a bit but deeper interactions', + 'Frequently engaged and deep interactions', + 'Only engaged a bit and shallow interactions', + 'Frequently engaged but shallow interactions', + ]; + + descriptions.forEach((description) => { + expect(screen.getByText(description)).toBeInTheDocument(); + }); + + expect( + screen.getByText(mockEngagement.hqla.toString()) + ).toBeInTheDocument(); + expect( + screen.getByText(mockEngagement.hqhe.toString()) + ).toBeInTheDocument(); + expect( + screen.getByText(mockEngagement.lqla.toString()) + ).toBeInTheDocument(); + expect( + screen.getByText(mockEngagement.lqhe.toString()) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccounts.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccounts.tsx new file mode 100644 index 00000000..3685055a --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccounts.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useState } from 'react'; +import TcEngagementAccountsHeader from './TcEngagementAccountsHeader'; +import TcEngagementAccountsContent from './TcEngagementAccountsContent'; +import { IEngagement } from '../../../../utils/interfaces'; + +interface ITcEngagementAccountsProps { + engagement: IEngagement; +} + +function TcEngagementAccounts({ engagement }: ITcEngagementAccountsProps) { + const [contentItems, setContentItems] = useState([ + { + bgColor: 'bg-[#D2F4CF]', + value: 0, + description: 'Only engaged a bit but deeper interactions', + tooltipText: + 'Number of users with low engagement (less than 3 interactions) but of high quality ( replying, mentioning, or quoting you)', + label: 'Low', + }, + { + bgColor: 'bg-[#3A9E2B]', + value: 0, + description: 'Frequently engaged and deep interactions', + tooltipText: + 'Number of users with high engagement (at least 3) and high quality (replies, quotes, or mentions you)', + }, + { + bgColor: 'bg-[#FBE8DA]', + value: 0, + description: 'Only engaged a bit and shallow interactions', + tooltipText: + 'Number of users with low engagement (less than 3 interactions) but of high quality ( replying, mentioning, or quoting you)', + label: 'Low', + }, + { + bgColor: 'bg-[#D2F4CF]', + value: 0, + description: 'Frequently engaged but shallow interactions', + tooltipText: + 'Number of users with high engagement (at least 3) but low quality (likes and retweets)', + label: 'High', + }, + ]); + + useEffect(() => { + if (engagement) { + const updatedContentItems = [...contentItems]; + + updatedContentItems[0].value = engagement.hqla; + updatedContentItems[1].value = engagement.hqhe; + updatedContentItems[2].value = engagement.lqla; + updatedContentItems[3].value = engagement.lqhe; + + setContentItems(updatedContentItems); + } + }, [engagement]); + + return ( +
+ + +
+ ); +} + +export default TcEngagementAccounts; diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsContent.spec.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsContent.spec.tsx new file mode 100644 index 00000000..5bc50d29 --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsContent.spec.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcEngagementAccountsContent from './TcEngagementAccountsContent'; + +describe('', () => { + const mockContentItems = [ + { + bgColor: 'bg-red', + value: 10, + description: 'Description 1', + tooltipText: 'Tooltip 1', + label: 'Label 1', + }, + { + bgColor: 'bg-blue', + value: 20, + description: 'Description 2', + tooltipText: 'Tooltip 2', + }, + { + bgColor: 'bg-yellow', + value: 30, + description: 'Description 3', + tooltipText: 'Tooltip 3', + label: 'Label 3', + }, + { + bgColor: 'bg-green', + value: 40, + description: 'Description 4', + tooltipText: 'Tooltip 4', + }, + ]; + + beforeEach(() => { + render(); + }); + + // Test 1: Check if the TcEngagementAccountContentItems component is rendered for each item. + it('renders content items correctly', () => { + mockContentItems.forEach((item) => { + expect(screen.getByText(item.description)).toBeInTheDocument(); + }); + }); + + // Test 2: Check if the labels 'High' and 'Low' are rendered. + it('renders the labels High and Low', () => { + ['High', 'Low'].forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + + // Test 3: Check if specific labels in content items are rendered. + it('renders content item labels correctly', () => { + mockContentItems.forEach((item) => { + if (item.label) { + expect(screen.getByText(item.label)).toBeInTheDocument(); + } + }); + }); +}); diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsContent.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsContent.tsx new file mode 100644 index 00000000..ace180f2 --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsContent.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import TcText from '../../../shared/TcText'; +import TcEngagementAccountContentItems from './TcEngagementAccountContentItems'; + +interface IContentItem { + bgColor: string; + value: number; + description: string; + tooltipText: string; + label?: string; +} + +interface ITcEngagementAccountsContentProps { + contentItems: IContentItem[]; +} + +function TcEngagementAccountsContent({ + contentItems, +}: ITcEngagementAccountsContentProps) { + const renderContentItems = (item: IContentItem, index: number) => ( +
+ + {item.label && ( + + )} +
+ ); + + return ( +
+
+ +
+
+ +
+
+ {['High', 'Low'].map((label, index) => ( +
+ +
+ {renderContentItems(contentItems[index * 2], index * 2)} + {renderContentItems(contentItems[index * 2 + 1], index * 2 + 1)} +
+
+ ))} +
+
+ ); +} + +export default TcEngagementAccountsContent; diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsHeader.spec.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsHeader.spec.tsx new file mode 100644 index 00000000..9aece62e --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsHeader.spec.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // For the "toBeInTheDocument" matcher + +import TcAudienceResponseHeader from './TcEngagementAccountsHeader'; + +describe('TcAudienceResponseHeader', () => { + it('renders the correct text', () => { + const { getByText } = render(); + + const headerText = getByText('Engagement by accounts'); + + expect(headerText).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsHeader.tsx b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsHeader.tsx new file mode 100644 index 00000000..db6f4477 --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/TcEngagementAccountsHeader.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import TcText from '../../../shared/TcText'; + +function TcEngagementAccountsHeader() { + return ( + <> + + + ); +} + +export default TcEngagementAccountsHeader; diff --git a/src/components/twitter/growth/engagementAccounts/index.ts b/src/components/twitter/growth/engagementAccounts/index.ts new file mode 100644 index 00000000..dbc6e621 --- /dev/null +++ b/src/components/twitter/growth/engagementAccounts/index.ts @@ -0,0 +1 @@ +import { default as TcEngagementAccounts } from './TcEngagementAccounts'; diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeature.spec.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeature.spec.tsx new file mode 100644 index 00000000..f15f09c5 --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeature.spec.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import TcvoteFeature from './TcvoteFeature'; + +// Mocking child components +jest.mock('./TcvoteFeatureHeader', () => { + return () =>
TcvoteFeatureHeader Mock
; +}); + +jest.mock('./TcvoteFeatureVotes/TcvoteFeatureVotes', () => { + return ({ handleSelectedFeatures }: any) => ( + + ); +}); + +describe('', () => { + test('renders without crashing', () => { + render(); + expect(screen.getByText('TcvoteFeatureHeader Mock')).toBeInTheDocument(); + expect(screen.getByText('Mock TcvoteFeatureVotes')).toBeInTheDocument(); + expect(screen.getByText('Vote now')).toBeInTheDocument(); + }); + + test('"Vote now" button is initially disabled', async () => { + render(); + const button = await waitFor(() => + screen.getByRole('button', { name: /Vote now/i }) + ); + expect(button).toBeDisabled(); + }); + test('"Vote now" button is enabled when features are selected', () => { + render(); + fireEvent.click(screen.getByText('Mock TcvoteFeatureVotes')); // Simulate selecting features + expect(screen.getByText('Vote now')).not.toBeDisabled(); + }); +}); diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeature.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeature.tsx new file mode 100644 index 00000000..2187feae --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeature.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import TcvoteFeatureHeader from './TcvoteFeatureHeader'; +import TcvoteFeatureVotes from './TcvoteFeatureVotes/TcvoteFeatureVotes'; +import TcButton from '../../../shared/TcButton'; + +function TcvoteFeature() { + const [nextFeature, setNextFeature] = useState>([ + false, + false, + false, + false, + ]); + + const handleSelectedFeatures = (selectedFeatures: boolean[]) => { + setNextFeature(selectedFeatures); + }; + + return ( +
+ + +
+ +
+
+ ); +} + +export default TcvoteFeature; diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureHeader.spec.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeatureHeader.spec.tsx new file mode 100644 index 00000000..a60accb2 --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureHeader.spec.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; // For the "toBeInTheDocument" matcher + +import TcvoteFeatureHeader from './TcvoteFeatureHeader'; + +describe('TcvoteFeatureHeader', () => { + it('renders the TcvoteFeatureHeader text', () => { + const { getByText } = render(); + + const headerText = getByText('Vote on our next feature'); + + expect(headerText).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureHeader.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeatureHeader.tsx new file mode 100644 index 00000000..7709dbfe --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureHeader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import TcText from '../../../shared/TcText'; + +function TcAudienceResponseHeader() { + return ( +
+ +
+ ); +} + +export default TcAudienceResponseHeader; diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotes.spec.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotes.spec.tsx new file mode 100644 index 00000000..89c15eca --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotes.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, fireEvent, screen, cleanup } from '@testing-library/react'; +import TcvoteFeatureVotes from './TcvoteFeatureVotes'; + +const TcvoteFeatureVotesMockList = [ + { + label: + 'Member Breakdown: see the accounts in each category to engage with them', + value: 0, + }, + { + label: 'Tweet Scheduling: create tweets directly from the dashboard ', + value: 1, + }, + { + label: + 'Cross Platform Integration: see how many active twitter followers are also active in your discord', + value: 2, + }, + { + label: + 'Targeting: Discover new twitter profiles similar to your most active followers', + value: 3, + }, +]; + +describe('', () => { + test('renders the list items correctly', () => { + for (const item of TcvoteFeatureVotesMockList) { + const mockFn = jest.fn(); + render(); + const textElement = screen.queryByText(item.label); + if (!textElement) { + console.error(`Failed to find: ${item.label}`); + } + cleanup(); + } + }); + + test('updates the state correctly when a checkbox is clicked', () => { + const mockFn = jest.fn(); + render(); + + const firstCheckbox = screen.getAllByRole('checkbox')[0]; + fireEvent.click(firstCheckbox); + + expect(mockFn).toHaveBeenCalledWith([true, false, false, false]); + }); +}); diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotes.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotes.tsx new file mode 100644 index 00000000..cc065682 --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotes.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from 'react'; +import TcvoteFeatureVotesItems from './TcvoteFeatureVotesItems'; + +const TcvoteFeatureVotesMockList = [ + { + label: + 'Member Breakdown: see the accounts in each category to engage with them', + value: 0, + }, + { + label: 'Tweet Scheduling: create tweets directly from the dashboard ', + value: 1, + }, + { + label: + 'Cross Platform Integration: see how many active twitter followers are also active in your discord', + value: 2, + }, + { + label: + 'Targeting: Discover new twitter profiles similar to your most active followers', + value: 3, + }, +]; + +interface ITcvoteFeatureVotesProps { + handleSelectedFeatures: (selectedFeatures: boolean[]) => void; +} + +function TcvoteFeatureVotes({ + handleSelectedFeatures, +}: ITcvoteFeatureVotesProps) { + const [nextFeatures, setNextFeatures] = useState>([ + false, + false, + false, + false, + ]); + + useEffect(() => { + handleSelectedFeatures(nextFeatures); + }, [nextFeatures]); + + const handleToggleCheckbox = (e: boolean, index: number) => { + setNextFeatures((prevState) => + prevState.map((item, idx) => (idx === index ? e : item)) + ); + }; + return ( +
+ {TcvoteFeatureVotesMockList.map((item, index) => ( + handleToggleCheckbox(event, index)} + color="secondary" + /> + ))} +
+ ); +} + +export default TcvoteFeatureVotes; diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotesItems.spec.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotesItems.spec.tsx new file mode 100644 index 00000000..372e1069 --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotesItems.spec.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import TcvoteFeatureVotesItems from './TcvoteFeatureVotesItems'; + +describe('', () => { + test('renders correctly', () => { + render( + + ); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + test('renders checked checkbox based on isChecked prop', () => { + const { rerender } = render( + + ); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + + rerender( + + ); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + test('calls handleToggleCheckbox when checkbox is clicked', () => { + const mockFn = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('checkbox')); + expect(mockFn).toHaveBeenCalled(); + }); +}); diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotesItems.tsx b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotesItems.tsx new file mode 100644 index 00000000..faeb10d5 --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/TcvoteFeatureVotesItems.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import TcCheckbox from '../../../../shared/TcCheckbox'; +import { FormControlLabel } from '@mui/material'; +import TcText from '../../../../shared/TcText'; + +interface ITcvoteFeatureVotesItemsProps { + label: string; + color: 'primary' | 'secondary'; + isChecked: boolean; + handleToggleCheckbox: (event: boolean) => void; +} + +function TcvoteFeatureVotesItems({ + label, + color, + isChecked, + handleToggleCheckbox, + ...props +}: ITcvoteFeatureVotesItemsProps) { + return ( +
+ } + control={ + handleToggleCheckbox(event.target.checked)} + /> + } + /> +
+ ); +} + +export default TcvoteFeatureVotesItems; diff --git a/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/index.ts b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/index.ts new file mode 100644 index 00000000..e293b34c --- /dev/null +++ b/src/components/twitter/growth/voteFeature/TcvoteFeatureVotes/index.ts @@ -0,0 +1 @@ +import { default as TcvoteFeatureVotes } from './TcvoteFeatureVotes'; diff --git a/src/components/twitter/growth/voteFeature/index.ts b/src/components/twitter/growth/voteFeature/index.ts new file mode 100644 index 00000000..7eb05f1b --- /dev/null +++ b/src/components/twitter/growth/voteFeature/index.ts @@ -0,0 +1 @@ +import { default as TcvoteFeature } from './TcvoteFeature'; diff --git a/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivity.spec.tsx b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivity.spec.tsx new file mode 100644 index 00000000..3e582e45 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivity.spec.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcYourAccountActivity from './TcYourAccountActivity'; +import TcYourAccountActivityHeader from './TcYourAccountActivityHeader'; + +describe('', () => { + const mockActivity = { + posts: 10, + likes: 100, + replies: 15, + retweets: 7, + mentions: 8, + }; + + beforeEach(() => { + render(); + }); + + // Test 1: Check if the TcYourAccountActivityHeader component is rendered. + it('renders the header component', () => { + render(); + }); + // Test 2: Check if the data is rendered correctly in the TcYourAccountActivityContent component. + it('renders the correct activity values and descriptions', () => { + expect(screen.getByText('Number of posts')).toBeInTheDocument(); + expect(screen.getByText(mockActivity.posts.toString())).toBeInTheDocument(); + + expect(screen.getByText('Likes')).toBeInTheDocument(); + expect(screen.getByText(mockActivity.likes.toString())).toBeInTheDocument(); + + expect(screen.getByText('Replies')).toBeInTheDocument(); + expect( + screen.getByText(mockActivity.replies.toString()) + ).toBeInTheDocument(); + + expect(screen.getByText('Retweets')).toBeInTheDocument(); + expect( + screen.getByText(mockActivity.retweets.toString()) + ).toBeInTheDocument(); + + expect(screen.getByText('Mentions')).toBeInTheDocument(); + expect( + screen.getByText(mockActivity.mentions.toString()) + ).toBeInTheDocument(); + }); + + // Test 3: Check transformation logic. This might be optional as it's more of an implementation detail. + it('transforms the activity data correctly', () => { + const expectedDescriptions = [ + 'Number of posts', + 'Likes', + 'Replies', + 'Retweets', + 'Mentions', + ]; + + expectedDescriptions.forEach((description) => { + expect(screen.getByText(description)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivity.tsx b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivity.tsx new file mode 100644 index 00000000..de2c7607 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivity.tsx @@ -0,0 +1,49 @@ +import React, { useState, useEffect } from 'react'; +import TcYourAccountActivityHeader from './TcYourAccountActivityHeader'; +import TcYourAccountActivityContent from './TcYourAccountActivityContent'; +import { IActivity } from '../../../../utils/interfaces'; +import { capitalizeFirstChar } from '../../../../helpers/helper'; + +interface IAccountActivityItem { + description: string; + value: number; + hasTooltipInfo: boolean; +} + +interface ITcYourAccountActivityProps { + activity: IActivity; +} + +function TcYourAccountActivity({ activity }: ITcYourAccountActivityProps) { + const [yourAccountActivityList, setYourAccountActivityList] = useState< + IAccountActivityItem[] + >([]); + + useEffect(() => { + if (activity) { + const newState = Object.keys(activity).map((key) => { + const activityKey = key as keyof IActivity; + + return { + description: + activityKey === 'posts' + ? 'Number of posts' + : capitalizeFirstChar(activityKey), + value: activity[activityKey], + hasTooltipInfo: false, + }; + }); + + setYourAccountActivityList(newState); + } + }, [activity]); + + return ( +
+ + +
+ ); +} + +export default TcYourAccountActivity; diff --git a/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityContent.spec.tsx b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityContent.spec.tsx new file mode 100644 index 00000000..98716f26 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityContent.spec.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcYourAccountActivityContent from './TcYourAccountActivityContent'; + +describe('', () => { + const mockData = [ + { description: 'Desc 1', value: 123, hasTooltipInfo: true }, + { description: 'Desc 2', value: 456, hasTooltipInfo: false }, + { description: 'Desc 3', value: 789, hasTooltipInfo: true }, + ]; + + beforeEach(() => { + render(); + }); + + it('renders the correct values and descriptions', () => { + mockData.forEach((item) => { + expect(screen.getByText(item.value.toString())).toBeInTheDocument(); + expect(screen.getByText(item.description)).toBeInTheDocument(); + }); + }); + + it('does not render the TcIconWithTooltip when hasTooltipInfo is false', () => { + // Filtering mockData for items with hasTooltipInfo as false + const itemsWithoutTooltip = mockData.filter((item) => !item.hasTooltipInfo); + + itemsWithoutTooltip.forEach((item) => { + expect( + screen.getByText(item.description).closest('div') + ).not.toHaveTextContent('Followers and non-followers'); + }); + }); +}); diff --git a/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityContent.tsx b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityContent.tsx new file mode 100644 index 00000000..5e57ded6 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityContent.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import TcCard from '../../../shared/TcCard'; +import TcIconWithTooltip from '../../../shared/TcIconWithTooltip'; +import TcText from '../../../shared/TcText'; + +interface IYourAccountActivityContentProps { + data: { + description: string; + value: number; + hasTooltipInfo: boolean; + }[]; +} + +function TcYourAccountActivityContent({ + data, +}: IYourAccountActivityContentProps) { + return ( +
+
+ {data && + data.map((el, index) => ( + + + +
+ {el.hasTooltipInfo ? ( + + ) : null} +
+
+ } + /> + ))} +
+ + ); +} + +export default TcYourAccountActivityContent; diff --git a/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityHeader.spec.tsx b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityHeader.spec.tsx new file mode 100644 index 00000000..e92f6527 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityHeader.spec.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TcYourAccountActivityHeader from './TcYourAccountActivityHeader'; + +describe('', () => { + beforeEach(() => { + render(); + }); + + it('renders the main header text', () => { + const headerText = screen.getByText('Your account activity'); + expect(headerText).toBeInTheDocument(); + expect(headerText.tagName).toBe('H6'); // if MUI's variant h6 is being translated to the HTML h6 tag + }); + + it('renders the subtext about engagement', () => { + const subtext = screen.getByText('How much you engage with others'); + expect(subtext).toBeInTheDocument(); + }); +}); diff --git a/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityHeader.tsx b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityHeader.tsx new file mode 100644 index 00000000..0f4b2f26 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/TcYourAccountActivityHeader.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import TcText from '../../../shared/TcText'; +import { StorageService } from '../../../../services/StorageService'; +import { IUser } from '../../../../utils/types'; +import TcLink from '../../../shared/TcLink'; + +function TcYourAccountActivityHeader() { + return ( +
+
+ + +
+
+ ); +} + +export default TcYourAccountActivityHeader; diff --git a/src/components/twitter/growth/yourAccountActivity/index.ts b/src/components/twitter/growth/yourAccountActivity/index.ts new file mode 100644 index 00000000..ca1a5011 --- /dev/null +++ b/src/components/twitter/growth/yourAccountActivity/index.ts @@ -0,0 +1 @@ +import { default as TcYourAccountActivity } from './TcYourAccountActivity'; diff --git a/src/context/ChannelContext.tsx b/src/context/ChannelContext.tsx new file mode 100644 index 00000000..db6addd8 --- /dev/null +++ b/src/context/ChannelContext.tsx @@ -0,0 +1,195 @@ +import React, { createContext, useState, useContext, useCallback } from 'react'; +import useAppStore from '../store/useStore'; + +export interface SubChannel { + channelId: string; + name: string; + parentId: string; + canReadMessageHistoryAndViewChannel: boolean; +} + +export interface Channel { + channelId: string; + title: string; + subChannels: SubChannel[]; +} + +export type SelectedSubChannels = { + [channelId: string]: { + [subChannelId: string]: boolean; + }; +}; + +interface ChannelContextProps { + channels: Channel[]; + loading: boolean; + selectedSubChannels: { + [channelId: string]: { [subChannelId: string]: boolean }; + }; + refreshData: ( + platformId: string, + property?: 'channel' | 'role', + selectedChannels?: string[] + ) => Promise; + handleSubChannelChange: (channelId: string, subChannelId: string) => void; + handleSelectAll: (channelId: string, subChannels: SubChannel[]) => void; + updateSelectedSubChannels: ( + allChannels: Channel[], + newSelectedSubChannels: string[] + ) => void; +} + +interface ChannelProviderProps { + children: React.ReactNode; +} + +const initialChannels: Channel[] = []; + +const initialSelectedSubChannels: SelectedSubChannels = {}; + +const initialChannelContextData: ChannelContextProps = { + channels: initialChannels, + loading: false, + selectedSubChannels: initialSelectedSubChannels, + refreshData: async ( + platformId: string, + property?: 'channel' | 'role', + selectedChannels?: string[] + ) => {}, + handleSubChannelChange: (channelId: string, subChannelId: string) => {}, + handleSelectAll: (channelId: string, subChannels: SubChannel[]) => {}, + updateSelectedSubChannels: ( + allChannels: Channel[], + newSelectedSubChannels: string[] + ) => {}, +}; + +export const ChannelContext = createContext( + initialChannelContextData +); + +export const ChannelProvider = ({ children }: ChannelProviderProps) => { + const { retrievePlatformProperties } = useAppStore(); + const [channels, setChannels] = useState([]); + const [selectedSubChannels, setSelectedSubChannels] = + useState({}); + const [loading, setLoading] = useState(false); + + const refreshData = useCallback( + async ( + platformId: string, + property: 'channel' | 'role' = 'channel', + selectedChannels?: string[] + ) => { + setLoading(true); + try { + const data = await retrievePlatformProperties({ property, platformId }); + setChannels(data); + if (selectedChannels) { + console.log('dsd'); + + updateSelectedSubChannels(data, selectedChannels); + } else { + const newSelectedSubChannels = data.reduce( + (acc: any, channel: any) => { + acc[channel.channelId] = channel.subChannels.reduce( + (subAcc: any, subChannel: any) => { + subAcc[subChannel.channelId] = + subChannel.canReadMessageHistoryAndViewChannel; + return subAcc; + }, + {} as { [subChannelId: string]: boolean } + ); + return acc; + }, + {} as SelectedSubChannels + ); + setSelectedSubChannels(newSelectedSubChannels); + } + return data; + } catch (error) { + return []; + } finally { + setLoading(false); + } + }, + [] + ); + + const handleSubChannelChange = (channelId: string, subChannelId: string) => { + setSelectedSubChannels((prev) => ({ + ...prev, + [channelId]: { + ...prev[channelId], + [subChannelId]: !prev[channelId]?.[subChannelId], + }, + })); + }; + + const handleSelectAll = (channelId: string, subChannels: SubChannel[]) => { + const allSelected = subChannels.every( + (subChannel) => + !subChannel.canReadMessageHistoryAndViewChannel || + selectedSubChannels[channelId]?.[subChannel.channelId] + ); + + const newSubChannelsState = subChannels.reduce((acc, subChannel) => { + if (subChannel.canReadMessageHistoryAndViewChannel) { + acc[subChannel.channelId] = !allSelected; + } else { + acc[subChannel.channelId] = false; + } + return acc; + }, {} as { [subChannelId: string]: boolean }); + + setSelectedSubChannels((prev) => ({ + ...prev, + [channelId]: newSubChannelsState, + })); + }; + + const updateSelectedSubChannels = ( + allChannels: Channel[], + newSelectedSubChannels: string[] + ) => { + setSelectedSubChannels((prevSelectedSubChannels: SelectedSubChannels) => { + const updatedSelectedSubChannels: SelectedSubChannels = { + ...prevSelectedSubChannels, + }; + + allChannels.forEach((channel) => { + const channelUpdates: { [subChannelId: string]: boolean } = {}; + + channel?.subChannels?.forEach((subChannel) => { + if (subChannel.canReadMessageHistoryAndViewChannel) { + channelUpdates[subChannel.channelId] = + newSelectedSubChannels.includes(subChannel.channelId); + } else { + channelUpdates[subChannel.channelId] = false; + } + }); + + updatedSelectedSubChannels[channel.channelId] = channelUpdates; + }); + console.log({ updatedSelectedSubChannels }); + + return updatedSelectedSubChannels; + }); + }; + + const value = { + channels, + loading, + selectedSubChannels, + handleSubChannelChange, + handleSelectAll, + refreshData, + updateSelectedSubChannels, + }; + + return ( + {children} + ); +}; + +export const useChannelContext = () => useContext(ChannelContext); diff --git a/src/context/SnackbarContext.tsx b/src/context/SnackbarContext.tsx new file mode 100644 index 00000000..9a68b715 --- /dev/null +++ b/src/context/SnackbarContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useState, useContext, ReactNode } from 'react'; +import Snackbar, { SnackbarOrigin } from '@mui/material/Snackbar'; +import Alert, { AlertColor } from '@mui/material/Alert'; + +type SnackbarContextType = { + showMessage: ( + message: string, + severity?: AlertColor, + position?: SnackbarOrigin + ) => void; +}; + +const SnackbarContext = createContext( + undefined +); + +type SnackbarProviderProps = { + children: ReactNode; +}; + +export const SnackbarProvider: React.FC = ({ + children, +}) => { + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(''); + const [severity, setSeverity] = useState('info'); + const [position, setPosition] = useState({ + vertical: 'bottom', + horizontal: 'center', + }); + + const showMessage = ( + newMessage: string, + newSeverity: AlertColor = 'info', + newPosition: SnackbarOrigin = { vertical: 'bottom', horizontal: 'center' } + ) => { + setMessage(newMessage); + setSeverity(newSeverity); + setPosition(newPosition); + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( + + {children} + + + {message} + + + + ); +}; + +export const useSnackbar = () => { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error('useSnackbar must be used within a SnackbarProvider'); + } + return context; +}; diff --git a/src/context/TokenContext.tsx b/src/context/TokenContext.tsx new file mode 100644 index 00000000..becf358c --- /dev/null +++ b/src/context/TokenContext.tsx @@ -0,0 +1,160 @@ +import React, { + useState, + useContext, + createContext, + useEffect, + useRef, + ReactNode, +} from 'react'; +import { StorageService } from '../services/StorageService'; +import { IToken } from '../utils/types'; +import { IDiscordModifiedCommunity } from '../utils/interfaces'; +import { SnackbarProvider } from './SnackbarContext'; +import useAppStore from '../store/useStore'; + +type TokenContextType = { + token: IToken | null; + community: IDiscordModifiedCommunity | null; + updateToken: (newToken: IToken) => void; + updateCommunity: (newCommunity: IDiscordModifiedCommunity) => void; + deleteCommunity: () => void; + clearToken: () => void; +}; + +const TokenContext = createContext(null); + +type TokenProviderProps = { + children: ReactNode; +}; + +export const TokenProvider: React.FC = ({ children }) => { + const { retrieveCommunityById } = useAppStore(); + const [token, setToken] = useState(null); + const [community, setCommunity] = useState( + null + ); + + // Use useRef to persist the interval ID across renders + const intervalIdRef = useRef(null); + + useEffect(() => { + const storedToken = StorageService.readLocalStorage('user'); + const storedCommunity = + StorageService.readLocalStorage('community'); + + if (storedToken) { + setToken(storedToken); + } + + if (storedCommunity) { + setCommunity(storedCommunity); + } + + const fetchAndUpdateCommunity = async () => { + try { + if (storedCommunity?.id) { + const updatedCommunity = await retrieveCommunityById( + storedCommunity.id + ); + if (updatedCommunity) { + setCommunity(updatedCommunity); + StorageService.writeLocalStorage( + 'community', + updatedCommunity + ); + } + } + } catch (error) { + console.error('Error fetching community:', error); + StorageService.removeLocalStorage('community'); + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + } + }; + + intervalIdRef.current = setInterval(fetchAndUpdateCommunity, 5000); + + return () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + }; + }, []); + + const updateToken = (newToken: IToken) => { + StorageService.writeLocalStorage('user', newToken); + setToken(newToken); + }; + + const updateCommunity = async (newCommunity: IDiscordModifiedCommunity) => { + // Clear the existing interval + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + + // Update the community and reset the interval + setCommunity(newCommunity); + StorageService.writeLocalStorage( + 'community', + newCommunity + ); + + // Restart the interval + intervalIdRef.current = setInterval(async () => { + try { + const updatedCommunity = await retrieveCommunityById(newCommunity.id); + if (updatedCommunity) { + setCommunity(updatedCommunity); + StorageService.writeLocalStorage( + 'community', + updatedCommunity + ); + } + } catch (error) { + console.error('Error fetching community:', error); + StorageService.removeLocalStorage('community'); + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + } + }, 5000); + }; + + const deleteCommunity = () => { + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current); + } + + StorageService.removeLocalStorage('community'); + setCommunity(null); + }; + + const clearToken = () => { + StorageService.removeLocalStorage('user'); + setToken(null); + }; + + return ( + + {children} + + ); +}; + +export const useToken = (): TokenContextType => { + const context = useContext(TokenContext); + if (!context) { + throw new Error('useToken must be used within a TokenProvider'); + } + return context; +}; diff --git a/src/helpers/PlatformHelper.tsx b/src/helpers/PlatformHelper.tsx new file mode 100644 index 00000000..47e5f079 --- /dev/null +++ b/src/helpers/PlatformHelper.tsx @@ -0,0 +1,29 @@ +type SelectedSubChannels = { + [channelId: string]: { + [subChannelId: string]: boolean; + }; +}; + +export default function updateTrueIDs( + selectedSubChannels: SelectedSubChannels, + currentTrueIDs: Set = new Set() +): string[] { + // Iterate over each channel + Object.keys(selectedSubChannels).forEach((channelId) => { + const subChannels = selectedSubChannels[channelId]; + + // Iterate over each sub-channel + Object.keys(subChannels).forEach((subChannelId) => { + if (subChannels[subChannelId]) { + // If the value is true, add the sub-channel ID to the set + currentTrueIDs.add(subChannelId); + } else { + // If the value is false and the ID is in the set, remove it + currentTrueIDs.delete(subChannelId); + } + }); + }); + + // Convert the Set to an Array before returning + return Array.from(currentTrueIDs); +} diff --git a/src/helpers/helper.ts b/src/helpers/helper.ts new file mode 100644 index 00000000..ee764a08 --- /dev/null +++ b/src/helpers/helper.ts @@ -0,0 +1,122 @@ +import { SelectedSubChannels } from '../context/ChannelContext'; +import { IDecodedToken } from '../utils/interfaces'; +import { IUser } from '../utils/types'; +import jwt_decode from 'jwt-decode'; + +export function capitalizeFirstChar(str: string): string { + return str?.charAt(0).toUpperCase() + str.slice(1); +} + +export function truncateCenter(text: string, maxLength: number = 10): string { + if (text.length <= maxLength) return text; + + const sideLength = Math.floor((maxLength - 3) / 2); // Subtract 3 for "..." + return text.slice(0, sideLength) + '...' + text.slice(-sideLength); +} + +export function decodeUserTokenDiscordId(user?: IUser): string | null { + if (user?.token?.accessToken) { + const decodedToken: IDecodedToken = jwt_decode(user.token.accessToken); + return decodedToken.sub; + } + return null; +} + +export function extractUrlParams(path: string): { [key: string]: string } { + const urlObj = new URL(path, window.location.origin); + const params = Array.from(urlObj.searchParams.entries()); + const queryParams: { [key: string]: string } = {}; + + params.forEach(([key, value]) => { + queryParams[key] = value; + }); + + return queryParams; +} + +export function debounce(func: Function, wait: number) { + let timeout: NodeJS.Timeout | null; + + return function executedFunction(...args: any[]) { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(later, wait); + }; +} + +export function calculateSelectedChannelSize( + selectedSubChannels: SelectedSubChannels +) { + let count = 0; + for (const channelId in selectedSubChannels) { + for (const subChannelId in selectedSubChannels[channelId]) { + if (selectedSubChannels[channelId][subChannelId]) { + count++; + } + } + } + return count; +} + +export function extractTrueSubChannelIds( + selectedSubChannels: SelectedSubChannels +) { + const trueSubChannelIds: string[] = []; + + Object.entries(selectedSubChannels).forEach(([channelId, subChannels]) => { + Object.entries(subChannels).forEach(([subChannelId, isSelected]) => { + if (isSelected) { + trueSubChannelIds.push(subChannelId); + } + }); + }); + + return trueSubChannelIds; +} + +export function isDarkColor(colorValue: string | number): boolean { + let hexColor: string; + + if (typeof colorValue === 'number') { + hexColor = + colorValue !== 0 + ? `#${colorValue.toString(16).padStart(6, '0')}` + : '#96A5A6'; + } else { + hexColor = colorValue; + } + + const r = parseInt(hexColor.substring(1, 3), 16); + const g = parseInt(hexColor.substring(3, 5), 16); + const b = parseInt(hexColor.substring(5, 7), 16); + + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + return luminance < 150; +} + +export function hexToRGBA(hex: string, opacity: number): string { + let r = 0, + g = 0, + b = 0; + + // 3 digits + if (hex.length === 4) { + r = parseInt(hex[1] + hex[1], 16); + g = parseInt(hex[2] + hex[2], 16); + b = parseInt(hex[3] + hex[3], 16); + } else if (hex.length === 7) { + // 6 digits + r = parseInt(hex.substring(1, 3), 16); + g = parseInt(hex.substring(3, 5), 16); + b = parseInt(hex.substring(5, 7), 16); + } + + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +} diff --git a/src/layouts/centricLayout.tsx b/src/layouts/centricLayout.tsx new file mode 100644 index 00000000..fe0ee30c --- /dev/null +++ b/src/layouts/centricLayout.tsx @@ -0,0 +1,24 @@ +import { Box, Container } from '@mui/material'; +import React from 'react'; +import Image from 'next/image'; +import tcLogo from '../assets/svg/tc-logo.svg'; + +interface ICentricLayout { + children: React.ReactNode; +} + +function centricLayout({ children }: ICentricLayout) { + return ( + + + + {children} + + + ); +} + +export default centricLayout; diff --git a/src/layouts/defaultLayout.tsx b/src/layouts/defaultLayout.tsx index 2aba59ca..2ed3c239 100644 --- a/src/layouts/defaultLayout.tsx +++ b/src/layouts/defaultLayout.tsx @@ -1,30 +1,16 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import Sidebar from '../components/layouts/Sidebar'; import SidebarXs from '../components/layouts/xs/SidebarXs'; -import useAppStore from '../store/useStore'; -import { StorageService } from '../services/StorageService'; -import { IUser } from '../utils/types'; +import TcPrompt from '../components/layouts/shared/TcPrompt'; -type Props = { +type IDefaultLayoutProps = { children: React.ReactNode; }; -export const defaultLayout = ({ children }: Props) => { - const { getGuilds, getGuildInfoByDiscord } = useAppStore(); - - useEffect(() => { - const user = StorageService.readLocalStorage('user'); - if (user) { - const { guildId } = user.guild; - getGuilds(); - if (guildId) { - getGuildInfoByDiscord(guildId); - } - } - }, []); - +export const defaultLayout = ({ children }: IDefaultLayoutProps) => { return ( <> +
diff --git a/src/lib/data/heatmap.ts b/src/lib/data/heatmap.ts index 78796165..94c46815 100644 --- a/src/lib/data/heatmap.ts +++ b/src/lib/data/heatmap.ts @@ -1,383 +1,182 @@ -const WEEK_DAYS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; +const WEEK_DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; -const HOURE_DAYS = [ - "12", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", -]; +const HOURE_DAYS = Array.from({ length: 24 }, (_, i) => i).map(String); -const chartData = [ - [0, 1, 10], - [0, 2, 20], - [0, 3, 40], - [0, 4, 50], - [0, 5, 30], - [0, 6, 0], - [0, 7, 70], - [0, 8, 0], - [0, 9, 90], - [0, 10, 0], - [0, 11, 2], - [0, 12, 4], - [0, 13, 1], - [0, 14, 1], - [0, 15, 3], - [0, 16, 4], - [0, 17, 6], - [0, 18, 4], - [0, 19, 4], - [0, 20, 3], - [0, 21, 3], - [0, 22, 2], - [0, 23, 5], - [0, 24, 5], - [1, 1, 10], - [1, 2, 20], - [1, 3, 25], - [1, 4, 50], - [1, 5, 30], - [1, 6, 0], - [1, 7, 30], - [1, 8, 0], - [1, 9, 90], - [1, 10, 0], - [1, 11, 2], - [1, 12, 4], - [1, 13, 1], - [1, 14, 1], - [1, 15, 3], - [1, 16, 4], - [1, 17, 6], - [1, 18, 4], - [1, 19, 4], - [1, 20, 3], - [1, 21, 3], - [1, 22, 2], - [1, 23, 5], - [1, 24, 5], - [2, 1, 10], - [2, 2, 20], - [2, 3, 40], - [2, 4, 50], - [2, 5, 30], - [2, 6, 0], - [2, 7, 70], - [2, 8, 0], - [2, 9, 90], - [2, 10, 0], - [2, 11, 2], - [2, 12, 4], - [2, 13, 1], - [2, 14, 1], - [2, 15, 3], - [2, 16, 4], - [2, 17, 6], - [2, 18, 4], - [2, 19, 4], - [2, 20, 3], - [2, 21, 3], - [2, 22, 2], - [2, 23, 5], - [2, 24, 5], - [3, 1, 10], - [3, 2, 20], - [3, 3, 25], - [3, 4, 50], - [3, 5, 30], - [3, 6, 0], - [3, 7, 30], - [3, 8, 0], - [3, 9, 90], - [3, 10, 0], - [3, 11, 2], - [3, 12, 4], - [3, 13, 1], - [3, 14, 1], - [3, 15, 3], - [3, 16, 4], - [3, 17, 6], - [3, 18, 4], - [3, 19, 4], - [3, 20, 3], - [3, 21, 3], - [3, 22, 2], - [3, 23, 5], - [3, 24, 5], - [4, 1, 10], - [4, 2, 20], - [4, 3, 40], - [4, 4, 50], - [4, 5, 30], - [4, 6, 0], - [4, 7, 70], - [4, 8, 0], - [4, 9, 90], - [4, 10, 100], - [4, 11, 2], - [4, 12, 4], - [4, 13, 1], - [4, 14, 1], - [4, 15, 3], - [4, 16, 4], - [4, 17, 6], - [4, 18, 4], - [4, 19, 4], - [4, 20, 3], - [4, 21, 3], - [4, 22, 2], - [4, 23, 5], - [4, 24, 5], - [5, 1, 10], - [5, 2, 20], - [5, 3, 25], - [5, 4, 50], - [5, 5, 30], - [5, 6, 0], - [5, 7, 30], - [5, 8, 0], - [5, 9, 90], - [5, 10, 0], - [5, 11, 2], - [5, 12, 4], - [5, 13, 1], - [5, 14, 1], - [5, 15, 3], - [5, 16, 4], - [5, 17, 6], - [5, 18, 4], - [5, 19, 4], - [5, 20, 3], - [5, 21, 3], - [5, 22, 2], - [5, 23, 5], - [5, 24, 5], - [6, 1, 10], - [6, 2, 20], - [6, 3, 40], - [6, 4, 50], - [6, 5, 30], - [6, 6, 0], - [6, 7, 70], - [6, 8, 0], - [6, 9, 90], - [6, 10, 0], - [6, 11, 2], - [6, 12, 4], - [6, 13, 1], - [6, 14, 1], - [6, 15, 3], - [6, 16, 4], - [6, 17, 6], - [6, 18, 4], - [6, 19, 4], - [6, 20, 3], - [6, 21, 3], - [6, 22, 2], - [6, 23, 5], - [6, 24, 5], - -].map((item) => [item[1], item[0], item[2] || 0]); - -const options = { - chart: { - type: "heatmap", - plotBorderWidth: 0, +const defaultHeatmapChartOptions = { + chart: { + type: 'heatmap', + plotBorderWidth: 0, + }, + title: { + text: null, + }, + legend: { + enabled: false, + }, + xAxis: { + categories: HOURE_DAYS, + tickInterval: 1, + labels: { + step: 1, + style: { + fontSize: '14px', + fontFamily: 'Inter', + }, }, + opposite: true, + gridLineWidth: 0, + lineWidth: 0, + lineColor: 'rgba(0,0,0,0.75)', + tickWidth: 0, + tickLength: 0, + tickColor: 'rgba(0,0,0,0.75)', title: { - text: null + text: 'Hour', + style: { + fontSize: '14px', + fontFamily: 'Inter', + }, + align: 'low', }, - legend: { - title: { - text: 'Number of interactions', - style: { - fontStyle: "bold", - }, - }, - align: "right", - layout: "horizental", - margin: 0, - verticalAlign: "top", - y: 0, - x: 25, - symbolHeight: 25, + }, + yAxis: { + categories: WEEK_DAYS, + lineWidth: 0, + gridLineWidth: 0, + title: 'Weekdays', + reversed: true, + labels: { + style: { + fontSize: '14px', + fontFamily: 'Inter', + }, }, - xAxis: { - categories: HOURE_DAYS, - tickInterval: 1, - labels: { - step: 1, - style: { - fontSize: "14px", - fontFamily: "Inter", - }, + }, + tooltip: { + enabled: false, + }, + colorAxis: { + min: 0, + minColor: '#F3F3F3', + maxColor: '#45367B', + max: 100, + stops: [ + [0, '#F3F3F3'], + [10 / 100, '#F3F3F3'], + [10 / 100, '#E3E9FF'], + [20 / 100, '#E3E9FF'], + [20 / 100, '#C5D2FF'], + [30 / 100, '#C5D2FF'], + [30 / 100, '#9971E7'], + [50 / 100, '#9971E7'], + [50 / 100, '#673FB5'], + [70 / 100, '#673FB5'], + [70 / 100, '#35205E'], + [1, '#35205E'], + ].map(([position, color]) => [position, color] as [number, string]), + }, + series: [ + { + name: 'Revenue', + borderWidth: 0.5, + borderColor: 'white', + states: { + hover: { + enabled: false, }, - opposite: true, - gridLineWidth: 0, - lineWidth: 0, - lineColor: "rgba(0,0,0,0.75)", - tickWidth: 0, - tickLength: 0, - tickColor: "rgba(0,0,0,0.75)", - title: { - text: "Hour", - style: { - fontSize: "14px", - fontFamily: "Inter", - }, - align: 'low', + }, + dataLabels: { + enabled: true, + style: { + fontSize: '14px', + fontFamily: 'Inter', + textOutline: 'none', + fontWeight: 'normal', }, + }, + pointPadding: 1.5, + data: Array.from({ length: 24 * 7 }, (_, i) => [ + i % 24, + Math.floor(i / 24), + 0, + ]), + colsize: 0.9, + rowsize: 0.8, }, - yAxis: { - categories: WEEK_DAYS, - lineWidth: 0, - gridLineWidth: 0, - title: "Weekdays", - reversed: true, - labels: { - style: { - fontSize: "14px", - fontFamily: "Inter", - }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 600, }, - }, - tooltip: { - enabled: false, - }, - colorAxis: { - min: 0, - minColor: '#F3F3F3', - maxColor: '#45367B', - max: 100, - stops: [ - [0, "#F1F3F3"], - [0.1, "#EBF2F5"], - [0.15, "#E0F1F7"], - [0.2, "#C4D8F8"], - [0.25, "#AEDFF0"], - [0.3, "#DAD0FF"], - [0.5, "#AE9DF0"], - [0.7, "#8474C0"], - [1, "#45367B"], - ], - }, - series: [ - { - name: "Revenue", - borderWidth: 0.5, - borderColor: "white", - states: { - hover: { - enabled: false - } + chartOptions: { + chart: { + scrollablePlotArea: { + minWidth: 1080, + }, + }, + legend: { + title: { + text: 'Number of interactions', + style: { + fontStyle: 'bold', + fontSize: '10px', + fontFamily: 'Inter', + }, + }, + align: 'left', + layout: 'horizental', + margin: 0, + verticalAlign: 'bottom', + y: 0, + x: 25, + symbolHeight: 20, + }, + xAxis: { + width: 1000, + labels: { + step: 1, + style: { + fontSize: '10px', + fontFamily: 'Inter', + }, }, - dataLabels: { + }, + yAxis: { + labels: { + style: { + fontSize: '10px', + fontFamily: 'Inter', + }, + }, + }, + series: [ + { + name: 'Revenue', + borderWidth: 0.5, + borderColor: 'white', + dataLabels: { enabled: true, style: { - fontSize: "14px", - fontFamily: "Inter", - textOutline: 'none', - fontWeight: "normal", + fontSize: '10px', + fontFamily: 'Inter', }, + }, + pointPadding: 0.8, + data: Array.from({ length: 24 * 7 }, (_, i) => [ + i % 24, + Math.floor(i / 24), + 0, + ]), + colsize: 0.9, + rowsize: 0.9, }, - pointPadding: 1.5, - data: chartData, - colsize: 0.9, - rowsize: 0.8, + ], }, + }, ], - responsive: { - rules: [{ - condition: { - maxWidth: 600 - }, - // Make the labels less space demanding on mobile - chartOptions: { - chart: { - scrollablePlotArea: { - minWidth: 1080, - }, - }, - legend: { - title: { - text: 'Number of interactions', - style: { - fontStyle: "bold", - fontSize: "10px", - fontFamily: "Inter", - }, - }, - align: "left", - layout: "horizental", - margin: 0, - verticalAlign: "bottom", - y: 0, - x: 25, - symbolHeight: 20, - }, - xAxis: { - width: 1000, - labels: { - step: 1, - style: { - fontSize: "10px", - fontFamily: "Inter", - }, - }, - }, - yAxis: { - labels: { - style: { - fontSize: "10px", - fontFamily: "Inter", - }, - }, - }, - series: [ - { - name: "Revenue", - borderWidth: 0.5, - borderColor: "white", - dataLabels: { - enabled: true, - style: { - fontSize: "10px", - fontFamily: "Inter", - }, - }, - pointPadding: .8, - data: chartData, - colsize: .9, - rowsize: .9, - }, - ], - } - }] - } + }, }; -export { - WEEK_DAYS, - HOURE_DAYS, - chartData, - options -}; \ No newline at end of file +export { WEEK_DAYS, HOURE_DAYS, defaultHeatmapChartOptions }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cd49631d..39959383 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -3,13 +3,12 @@ import type { AppProps } from 'next/app'; import React, { useEffect } from 'react'; import { hotjar } from 'react-hotjar'; -import '@fortawesome/fontawesome-svg-core/styles.css'; // import Font Awesome CSS +import '@fortawesome/fontawesome-svg-core/styles.css'; import { config } from '@fortawesome/fontawesome-svg-core'; -config.autoAddCss = false; // Tell Font Awesome to skip adding the CSS automatically since it's being imported above - +config.autoAddCss = false; type ComponentWithPageLayout = AppProps & { Component: AppProps['Component'] & { - pageLayout?: React.ComponentType | any; // should fix type + pageLayout?: React.ComponentType | any; }; }; @@ -24,6 +23,7 @@ import AmplitudeAnalytics from '../components/global/AmplitudeAnalytics'; import Script from 'next/script'; import { usePageViewTracking } from '../helpers/amplitudeHelper'; import SafaryClubScript from '../components/global/SafaryClubScript'; +import { TokenProvider } from '../context/TokenContext'; export default function App({ Component, pageProps }: ComponentWithPageLayout) { usePageViewTracking(); @@ -57,15 +57,17 @@ export default function App({ Component, pageProps }: ComponentWithPageLayout) { `} - {Component.pageLayout ? ( - - - - - - ) : ( - - )} + + {Component.pageLayout ? ( + + + + + + ) : ( + + )} + diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index 8e687a91..e4536e89 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -61,7 +61,7 @@ const ErrorPage: NextComponentType< label={'Community Insights'} /> router.push('/tryNow')} + onClick={() => router.push('/centric')} classes={'text-black'} variant="outlined" label={'Connect your community'} diff --git a/src/pages/callback.tsx b/src/pages/callback.tsx index 9290b9ed..7ae311f7 100644 --- a/src/pages/callback.tsx +++ b/src/pages/callback.tsx @@ -1,208 +1,174 @@ +import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; -import { IUser, callbackUrlParams } from '../utils/types'; +import { extractUrlParams } from '../helpers/helper'; +import { StatusCode } from '../utils/enums'; import SimpleBackdrop from '../components/global/LoadingBackdrop'; import { StorageService } from '../services/StorageService'; -import { toast } from 'react-toastify'; -import { BiError } from 'react-icons/bi'; - -export default function callback() { +import { IRetrieveCommunitiesProps } from '../store/types/ICentric'; +import useAppStore from '../store/useStore'; +import { ICommunity, metaData } from '../utils/interfaces'; + +export type CommunityWithoutAvatar = Omit; +interface Params { + name: string; + platform: string; + id: string; + username?: string; + profileImageUrl?: string; + icon?: string; +} +/** + * Callback Component. + * + * This component is designed to handle the callback after a user tries to authorize + * with Discord. Based on the status code received in the URL parameters, it will display + * appropriate messages to the user. + */ +function Callback() { + // State to store the displayed message + const [message, setMessage] = useState(null); + + // Next.js router instance const router = useRouter(); - const [loading, toggleLoading] = useState(true); - if (typeof window !== 'undefined') { - useEffect(() => { - if ( - router?.query && - Object.keys(router?.query) && - Object.keys(router?.query).length > 0 - ) { - const routerParams: callbackUrlParams = Object.assign(router.query); - statusDecoder(routerParams); + // Method to retrieve communities from the store. + const { retrieveCommunities, createNewPlatform } = useAppStore(); + + /** + * Asynchronously fetches communities. + * Depending on the presence of communities, it will redirect to either the terms and conditions + * page or the community selection page. + */ + const fetchCommunities = async () => { + const params: IRetrieveCommunitiesProps = { page: 1, limit: 10 }; + try { + const communities = await retrieveCommunities(params); + if (communities.results.length === 0) { + router.push('/centric/tac'); } else { - router.push('/tryNow'); + router.push('/centric/select-community'); } - }, [router]); - } - - const notify = () => { - toast('Discord authentication faild.please try again.', { - position: 'bottom-left', - autoClose: 3000, - hideProgressBar: true, - closeOnClick: false, - pauseOnHover: true, - draggable: false, - progress: undefined, - closeButton: false, - theme: 'light', - icon: , - }); + } catch (error) { + console.error('Failed to retrieve communities:', error); + } }; - const statusDecoder = (params: callbackUrlParams) => { - const { statusCode } = params; - let user = StorageService.readLocalStorage('user'); - switch (statusCode) { - case '490': - notify(); - router.push('/tryNow'); - break; + const handleCreateNewPlatform = async (params: Params) => { + const community = + StorageService.readLocalStorage('community'); - case '491': - notify(); - router.push('/settings'); - break; + if (!community) { + console.error('Community not found in local storage.'); + return; + } - case '501': - router.push({ - pathname: '/tryNow', - query: { - statusCode: params.statusCode, - accessToken: params.accessToken, - accessExp: params.accessExp, - refreshExp: params.refreshExp, - refreshToken: params.refreshToken, - guildId: params.guildId, - guildName: params.guildName, - }, - }); - break; + let metadata: metaData = { + id: params.id, + }; - case '502': - router.push({ - pathname: '/tryNow', - query: { - statusCode: params.statusCode, - accessToken: params.accessToken, - accessExp: params.accessExp, - refreshExp: params.refreshExp, - refreshToken: params.refreshToken, - guildId: params.guildId, - guildName: params.guildName, - }, - }); - break; + if (params.platform === 'twitter') { + metadata.username = params.username; + metadata.profileImageUrl = params.profileImageUrl; + } else if (params.platform === 'discord') { + metadata.icon = params.icon; + metadata.name = params.name; + } - case '503': - StorageService.writeLocalStorage('user', { - guild: { - guildId: params.guildId, - guildName: params.guildName, - }, - token: { - accessToken: params.accessToken, - accessExp: params.accessExp, - refreshToken: params.refreshToken, - refreshExp: params.refreshExp, - }, - }); - router.push({ - pathname: '/', - }); - break; + const payload = { + name: params.platform, + community: community.id, + metadata: metadata, + }; - case '504': - StorageService.writeLocalStorage('user', { - guild: { - guildId: params.guildId, - guildName: params.guildName, - }, - token: { - accessToken: params.accessToken, - accessExp: params.accessExp, - refreshToken: params.refreshToken, - refreshExp: params.refreshExp, - }, - }); - router.push({ - pathname: '/', - }); - break; + try { + const data = await createNewPlatform(payload); + if (!data) { + router.push('community-settings'); + } + router.push(`/community-settings/platform/${data.id}`); + } catch (error) { + console.error('Failed to create new platform:', error); + } + }; + + /** + * Handles the display message based on the received status code. + * + * @param {StatusCode} code - The status code received from the URL parameters. + */ + const handleStatusCode = (code: StatusCode, params: any) => { + switch (code) { + case StatusCode.DISCORD_AUTHORIZATION_SUCCESSFUL_FIRST_TIME: + setMessage('Welcome! Authorization for sign-in was successful.'); + StorageService.writeLocalStorage('user', params); + fetchCommunities(); - case '601': - StorageService.writeLocalStorage('user', { - guild: { - guildId: params.guildId, - guildName: params.guildName, - }, - token: { - accessToken: params.accessToken, - accessExp: params.accessExp, - refreshToken: params.refreshToken, - refreshExp: params.refreshExp, - }, - }); - router.push('/'); break; - case '602': - StorageService.removeLocalStorage('user'); - router.push('/tryNow'); + case StatusCode.REPEATED_DISCORD_AUTHORIZATION_ATTEMPT: + setMessage( + 'You have authorized before and are trying to authorize again.' + ); + StorageService.writeLocalStorage('user', params); + fetchCommunities(); + break; - case '603': - StorageService.writeLocalStorage('user', { - guild: { - guildId: params.guildId, - guildName: params.guildName, - }, - token: { - accessToken: params.accessToken, - accessExp: params.accessExp, - refreshToken: params.refreshToken, - refreshExp: params.refreshExp, - }, - }); - router.push('/'); + case StatusCode.DISCORD_AUTHORIZATION_FAILURE: + setMessage('Authorization failed. Please try again.'); + handleCreateNewPlatform(params); break; - case '701': - if (user) { - StorageService.writeLocalStorage('user', { - guild: { - guildId: params.guildId, - guildName: params.guildName, - }, - token: user.token, - }); - router.push({ - pathname: '/settings', - query: { - guildId: params.guildId, - guildName: params.guildName, - isSuccessful: true, - }, - }); - } + case StatusCode.DISCORD_AUTHORIZATION_FROM_SETTINGS: + setMessage('Authorizion complete from settings page.'); + handleCreateNewPlatform(params); break; - case '702': - if (user) { - StorageService.writeLocalStorage('user', { - guild: { - guildId: params.guildId, - guildName: params.guildName, - }, - token: user.token, - }); - router.push({ - pathname: '/settings', - query: { - guildId: params.guildId, - guildName: params.guildName, - isSuccessful: true, - }, - }); - } + case StatusCode.TWITTER_AUTHORIZATION_SUCCESSFUL: + setMessage('Authorizion complete from settings page.'); + handleCreateNewPlatform(params); break; + case StatusCode.TWITTER_AUTHORIZATION_FAILURE: + setMessage('Twitter Authorization failed.'); + router.push('/community-settings'); + + case StatusCode.DISCORD_AUTHORIZATION_FAILURE_FROM_SETTINGS: + setMessage('Discord Authorization during setup on setting faield.'); + router.push('/community-settings'); + default: + console.error('Unexpected status code received:', code); + setMessage('An unexpected error occurred. Please try again later.'); break; } }; - if (loading) { - return ; - } + /** + * useEffect hook to handle status codes. + * + * It waits until the router instance is ready, extracts the parameters from the URL, + * and then handles the status code accordingly. + */ + useEffect(() => { + if (router.isReady) { + const params = extractUrlParams(router.asPath); + + if ( + params.statusCode && + Object.values(StatusCode).includes(params.statusCode as StatusCode) + ) { + handleStatusCode(params.statusCode as StatusCode, params); + } else { + console.error('Invalid or no status code found in the URL.'); + setMessage( + 'An error occurred while processing your request. Please try again.' + ); + } + } + }, [router.isReady]); + + return ; } + +export default Callback; diff --git a/src/pages/centric/create-new-community.tsx b/src/pages/centric/create-new-community.tsx new file mode 100644 index 00000000..d32928c8 --- /dev/null +++ b/src/pages/centric/create-new-community.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import centricLayout from '../../layouts/centricLayout'; +import TcText from '../../components/shared/TcText'; +import TcBoxContainer from '../../components/shared/TcBox/TcBoxContainer'; +import TcInput from '../../components/shared/TcInput'; +import TcCheckbox from '../../components/shared/TcCheckbox'; +import { FormControlLabel } from '@mui/material'; +import TcLink from '../../components/shared/TcLink'; +import TcButton from '../../components/shared/TcButton'; +import router from 'next/router'; +import useAppStore from '../../store/useStore'; +import SimpleBackdrop from '../../components/global/LoadingBackdrop'; +import { useToken } from '../../context/TokenContext'; + +function CreateNewCommunity() { + const { createNewCommunitie } = useAppStore(); + const { updateCommunity } = useToken(); + + const [loading, setLoading] = useState(false); + const [communityName, setCommunityName] = useState(''); + const [readTermsAndCondition, setReadTermsAndCondition] = + useState(false); + + const handleCreateNewCommunitie = async () => { + setLoading(true); + const community = await createNewCommunitie({ + name: communityName, + tcaAt: new Date().toISOString(), + }); + + updateCommunity(community); + + router.push('/'); + }; + if (loading) { + return ( + <> + + + ); + } + return ( + + +
+ + setCommunityName(e.target.value)} + /> +
+ + {'I understand and agree to the '} + + Privacy Policy + + {' and '} + + Terms of Service. + + + } + variant={'subtitle2'} + /> + } + control={ + setReadTermsAndCondition(e.target.checked)} + /> + } + /> +
+ handleCreateNewCommunitie()} + /> +
+
+ } + /> + ); +} + +CreateNewCommunity.pageLayout = centricLayout; + +export default CreateNewCommunity; diff --git a/src/pages/centric/index.tsx b/src/pages/centric/index.tsx new file mode 100644 index 00000000..e6b655f5 --- /dev/null +++ b/src/pages/centric/index.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import centricLayout from '../../layouts/centricLayout'; +import TcBoxContainer from '../../components/shared/TcBox/TcBoxContainer'; +import TcText from '../../components/shared/TcText'; +import TcButton from '../../components/shared/TcButton'; +import useAppStore from '../../store/useStore'; + +function Index() { + const { discordAuthorization } = useAppStore(); + return ( +
+ + +
+ discordAuthorization()} + /> +
+ +
+ } + /> + + ); +} + +Index.pageLayout = centricLayout; + +export default Index; diff --git a/src/pages/centric/select-community.tsx b/src/pages/centric/select-community.tsx new file mode 100644 index 00000000..2cfd6858 --- /dev/null +++ b/src/pages/centric/select-community.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import centricLayout from '../../layouts/centricLayout'; +import TcBoxContainer from '../../components/shared/TcBox/TcBoxContainer'; +import TcSelectCommunity from '../../components/centric/selectCommunity/TcSelectCommunity'; + +function SelectCommunity() { + return ( + <> + } + /> + + ); +} + +SelectCommunity.pageLayout = centricLayout; + +export default SelectCommunity; diff --git a/src/pages/centric/tac.tsx b/src/pages/centric/tac.tsx new file mode 100644 index 00000000..65cfbdbc --- /dev/null +++ b/src/pages/centric/tac.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import centricLayout from '../../layouts/centricLayout'; +import TcBoxContainer from '../../components/shared/TcBox/TcBoxContainer'; +import TcText from '../../components/shared/TcText'; +import TcCheckbox from '../../components/shared/TcCheckbox'; +import { FormControlLabel } from '@mui/material'; +import TcLink from '../../components/shared/TcLink'; +import TcButton from '../../components/shared/TcButton'; +import router from 'next/router'; +import useAppStore from '../../store/useStore'; +import { StorageService } from '../../services/StorageService'; +import SimpleBackdrop from '../../components/global/LoadingBackdrop'; + +function Tac() { + const { patchUser } = useAppStore(); + const [loading, setLoading] = useState(false); + const [acceptPrivacyAndPolicy, setAcceptPrivacyAndPolicy] = + useState(false); + + const handleAcceptTerms = async () => { + setLoading(true); + const payload = { + tcaAt: new Date().toISOString(), + }; + + try { + await patchUser(payload); + + StorageService.updateLocalStorageWithObject( + 'user', + 'tcaAt', + payload.tcaAt + ); + + router.push('/centric/select-community'); + } catch (error) { + console.error('Failed to accept terms:', error); + } + setLoading(false); + }; + + if (loading) { + return ; + } + + return ( +
+ + + + Please take a moment to familiarize yourself with
our + terms and conditions. + + } + /> + + {'I understand and agree to the '} + + Privacy Policy and Terms of Service. + + + } + variant={'subtitle2'} + /> + } + control={ + setAcceptPrivacyAndPolicy(e.target.checked)} + /> + } + /> +
+ handleAcceptTerms()} + /> +
+
+ } + /> + + ); +} + +Tac.pageLayout = centricLayout; + +export default Tac; diff --git a/src/pages/communityHealth.tsx b/src/pages/community-health.tsx similarity index 85% rename from src/pages/communityHealth.tsx rename to src/pages/community-health.tsx index 5785c9e6..6cc408ac 100644 --- a/src/pages/communityHealth.tsx +++ b/src/pages/community-health.tsx @@ -5,15 +5,15 @@ import Fragmentation from '../components/pages/communityHealth/Fragmentation'; import Decentralization from '../components/pages/communityHealth/Decentralization'; import HeaderSection from '../components/pages/communityHealth/HeaderSection'; import useAppStore from '../store/useStore'; -import { StorageService } from '../services/StorageService'; -import { IUser } from '../utils/types'; import SimpleBackdrop from '../components/global/LoadingBackdrop'; import { IDecentralisationScoreResponse, IFragmentationScoreResponse, } from '../utils/interfaces'; +import { useToken } from '../context/TokenContext'; function CommunityHealth() { + const { community } = useToken(); const { getDecentralisation, getFragmentation, isLoading } = useAppStore(); const [decentralisationScoreData, setDecentralisationScoreData] = useState(null); @@ -21,12 +21,12 @@ function CommunityHealth() { useState(null); useEffect(() => { - const storedUser = StorageService.readLocalStorage('user'); + const platformId = community?.platforms[0]?.id; - if (storedUser?.guild.guildId) { + if (platformId) { Promise.all([ - getDecentralisation(storedUser.guild.guildId), - getFragmentation(storedUser.guild.guildId), + getDecentralisation(platformId), + getFragmentation(platformId), ]).then(([decentralisationRes, fragmentationRes]) => { setDecentralisationScoreData(decentralisationRes); setFragmentationScoreData(fragmentationRes); diff --git a/src/pages/community-settings/index.tsx b/src/pages/community-settings/index.tsx new file mode 100644 index 00000000..4f7cbc10 --- /dev/null +++ b/src/pages/community-settings/index.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; +import { defaultLayout } from '../../layouts/defaultLayout'; +import SEO from '../../components/global/SEO'; +import TcBoxContainer from '../../components/shared/TcBox/TcBoxContainer'; +import TcText from '../../components/shared/TcText'; +import TcCommunityIntegrations from '../../components/communitySettings/communityIntegrations/TcCommunityIntegrations'; +import TcIntegrationDialog from '../../components/pages/communitySettings/TcIntegrationDialog'; +import { useRouter } from 'next/router'; +import TcSwitchCommunity from '../../components/communitySettings/switchCommunity/TcSwitchCommunity'; +import SimpleBackdrop from '../../components/global/LoadingBackdrop'; +import { ChannelProvider } from '../../context/ChannelContext'; + +function index() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [showDialog, setShowDialog] = useState(false); + const [dialogContent, setDialogContent] = useState({ + title: '', + bodyContent: <>, + dialogButtonText: '', + }); + + useEffect(() => { + setLoading(true); + if (router.query.platform) { + setShowDialog(true); + if (router.query.platform === 'Discord') { + setDialogContent({ + title: 'Welcome to TogetherCrew!', + bodyContent: <>To see the data, please connect your community, + dialogButtonText: 'I Understand!', + }); + } + } else { + setShowDialog(false); + } + setLoading(false); + }, [router.query]); + + const handleClose = () => { + setShowDialog(false); + router.push(router.pathname); + }; + + if (loading) { + return ; + } + + return ( + <> + + +
+ + +
+ + +
+
+ } + /> + + +
+ + ); +} + +index.pageLayout = defaultLayout; + +export default index; diff --git a/src/pages/community-settings/platform/[id].tsx b/src/pages/community-settings/platform/[id].tsx new file mode 100644 index 00000000..10b906ef --- /dev/null +++ b/src/pages/community-settings/platform/[id].tsx @@ -0,0 +1,27 @@ +import SEO from '../../../components/global/SEO'; +import TcPlatform from '../../../components/communitySettings/platform'; +import { defaultLayout } from '../../../layouts/defaultLayout'; +import { ChannelProvider } from '../../../context/ChannelContext'; +import TcBreadcrumbs from '../../../components/shared/TcBreadcrumbs'; + +function PlatformConfigurations() { + return ( + <> + + +
+ + +
+
+ + ); +} + +PlatformConfigurations.pageLayout = defaultLayout; + +export default PlatformConfigurations; diff --git a/src/pages/growth.tsx b/src/pages/growth.tsx new file mode 100644 index 00000000..8cd3cee8 --- /dev/null +++ b/src/pages/growth.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react'; +import { defaultLayout } from '../layouts/defaultLayout'; +import SEO from '../components/global/SEO'; +import TcText from '../components/shared/TcText'; +import TcBoxContainer from '../components/shared/TcBox/TcBoxContainer'; +import TcYourAccountActivity from '../components/twitter/growth/yourAccountActivity/TcYourAccountActivity'; +import TcAudienceResponse from '../components/twitter/growth/audienceResponse/TcAudienceResponse'; +import TcEngagementAccounts from '../components/twitter/growth/engagementAccounts/TcEngagementAccounts'; +import useAppStore from '../store/useStore'; +import { StorageService } from '../services/StorageService'; +import { IUser } from '../utils/types'; +import SimpleBackdrop from '../components/global/LoadingBackdrop'; +import { IDataTwitter } from '../utils/interfaces'; +import TcAccountActivity from '../components/twitter/growth/accountActivity/TcAccountActivity'; + +function growth() { + const user = StorageService.readLocalStorage('user'); + + const [data, setData] = useState({ + activity: { + posts: 0, + replies: 0, + retweets: 0, + likes: 0, + mentions: 0, + }, + audience: { + replies: 0, + retweets: 0, + likes: 0, + mentions: 0, + }, + engagement: { + hqla: 0, + hqhe: 0, + lqla: 0, + lqhe: 0, + }, + account: { + follower: 0, + engagement: 0, + }, + }); + + const [loading, setLoading] = useState(true); + + const { + twitterActivityAccount, + twitterAudienceAccount, + twitterEngagementAccount, + twitterAccount, + refreshTwitterMetrics, + } = useAppStore(); + + const updateTwitterMetrics = () => { + const lastTwitterMetricsDateStr = StorageService.readLocalStorage( + 'lastTwitterMetricsRefreshDate', + 'string' + ); + + if (!lastTwitterMetricsDateStr) { + return; + } + + const lastTwitterMetricsDate = new Date(lastTwitterMetricsDateStr); + + const now = new Date(); + const lastRefresh = new Date(lastTwitterMetricsDate); + + const differenceInMillis = now.getTime() - lastRefresh.getTime(); + + if (differenceInMillis >= 24 * 60 * 60 * 1000) { + refreshTwitterMetrics(); + StorageService.writeLocalStorage( + 'lastTwitterMetricsRefreshDate', + new Date().toISOString() + ); + } + return; + }; + + useEffect(() => { + const fetchTwitterMetrics = async () => { + const twitterId = user?.twitter?.twitterId; + + if (!twitterId) { + setLoading(false); + return; + } + + setLoading(true); + + try { + const [ + activityResponse, + audienceResponse, + engagementResponse, + accountResponse, + ] = await Promise.all([ + twitterActivityAccount(), + twitterAudienceAccount(), + twitterEngagementAccount(), + twitterAccount(), + ]); + + setData({ + activity: activityResponse, + audience: audienceResponse, + engagement: engagementResponse, + account: accountResponse, + }); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + updateTwitterMetrics(); + fetchTwitterMetrics(); + }, []); + + if (loading) { + return ; + } + + return ( + <> + +
+ + +
+ } + contentContainerChildren={ +
+ + + + +
+ } + /> + + + ); +} + +growth.pageLayout = defaultLayout; + +export default growth; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 80174a84..6bcb0683 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,52 +1,20 @@ -import MainSection from '../components/pages/pageIndex/MainSection'; import { defaultLayout } from '../layouts/defaultLayout'; import SEO from '../components/global/SEO'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { StorageService } from '../services/StorageService'; -import { IUser } from '../utils/types'; -import { useRouter } from 'next/router'; import EmptyState from '../components/global/EmptyState'; import Image from 'next/image'; import emptyState from '../assets/svg/empty-state.svg'; -import useAppStore from '../store/useStore'; -import { Alert, Collapse } from '@mui/material'; import React from 'react'; +import ActiveMemberComposition from '../components/pages/pageIndex/ActiveMemberComposition'; +import HeatmapChart from '../components/pages/pageIndex/HeatmapChart'; +import MemberInteractionGraph from '../components/pages/pageIndex/MemberInteractionGraph'; +import { ChannelProvider } from '../context/ChannelContext'; +import { useToken } from '../context/TokenContext'; function Dashboard(): JSX.Element { const [alertStateOpen, setAlertStateOpen] = useState(false); - - const { guilds } = useAppStore(); - const router = useRouter(); - - useEffect(() => { - const user = StorageService.readLocalStorage('user'); - if (user) { - const { token } = user; - if (!token.accessToken) { - router.replace('/tryNow'); - } - } else { - router.replace('/tryNow'); - } - }, []); - - if (typeof window !== 'undefined') { - useEffect(() => { - const show_analysis_state: { isRead: boolean; visible: boolean } = - StorageService.readLocalStorage('analysis_state') || { - isRead: false, - visible: true, - }; - - if (show_analysis_state && !show_analysis_state.isRead) { - StorageService.writeLocalStorage('analysis_state', { - isRead: false, - visible: guilds[0]?.isInProgress, - }); - setAlertStateOpen(guilds[0]?.isInProgress); - } - }, [guilds]); - } + const { community } = useToken(); const toggleAnalysisState = () => { StorageService.writeLocalStorage('analysis_state', { @@ -56,7 +24,7 @@ function Dashboard(): JSX.Element { setAlertStateOpen(false); }; - if (guilds && guilds.length === 0) { + if (!community || community?.platforms?.length === 0) { return ( <> @@ -68,32 +36,20 @@ function Dashboard(): JSX.Element { return ( <> - - {guilds && guilds[0].isInProgress ? ( - - Data import is in progress. It might take up to 6 hours to finish - the data import. Once it is done we will send you a message on - Discord. - - ) : ( - '' - )} - - -
- -
+ +
+
+

+ Community Insights +

+
+ + + +
+
+
+
); } diff --git a/src/pages/membersInteraction.tsx b/src/pages/membersInteraction.tsx index aa9615d1..335b7c3a 100644 --- a/src/pages/membersInteraction.tsx +++ b/src/pages/membersInteraction.tsx @@ -5,11 +5,11 @@ import { AiOutlineExclamationCircle, AiOutlineLeft } from 'react-icons/ai'; import Link from '../components/global/Link'; import { Paper, Popover } from '@mui/material'; import useAppStore from '../store/useStore'; -import { StorageService } from '../services/StorageService'; import HintBox from '../components/pages/memberInteraction/HintBox'; import { IUser } from '../utils/types'; import SimpleBackdrop from '../components/global/LoadingBackdrop'; import dynamic from 'next/dynamic'; +import { useToken } from '../context/TokenContext'; const ForceGraphComponent = dynamic( () => @@ -87,6 +87,8 @@ const transformApiResponseToMockData = (apiResponse: any[]) => { }; export default function membersInteraction() { + const { community } = useToken(); + const [nodes, setNodes] = useState([]); const [links, setLinks] = useState([]); @@ -100,19 +102,16 @@ export default function membersInteraction() { const { getMemberInteraction, isLoading } = useAppStore(); useEffect(() => { - const storedUser = StorageService.readLocalStorage('user'); - setUser(storedUser); - - if (storedUser && storedUser.guild) { - getMemberInteraction(storedUser.guild.guildId).then( - (apiResponse: any[]) => { - const { nodes, links } = transformApiResponseToMockData(apiResponse); - const nodeSizes = nodes.map((node) => node.size); - setNodes(nodes); - setLinks(links); - setNodeSizes(nodeSizes); - } - ); + const platformId = community?.platforms[0]?.id; + + if (platformId) { + getMemberInteraction(platformId).then((apiResponse: any[]) => { + const { nodes, links } = transformApiResponseToMockData(apiResponse); + const nodeSizes = nodes.map((node) => node.size); + setNodes(nodes); + setLinks(links); + setNodeSizes(nodeSizes); + }); } }, []); diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx deleted file mode 100644 index 86d5d0cf..00000000 --- a/src/pages/settings.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import SEO from '../components/global/SEO'; -import { defaultLayout } from '../layouts/defaultLayout'; -import { FiCalendar } from 'react-icons/fi'; -import { FaHashtag, FaCodeBranch, FaRegCheckCircle } from 'react-icons/fa'; -import Accardion from '../components/global/Accardion'; -import { Paper, TextField } from '@mui/material'; -import CustomButton from '../components/global/CustomButton'; -import ChannelSelection from '../components/pages/settings/ChannelSelection'; -import IntegrateDiscord from '../components/pages/settings/IntegrateDiscord'; -import { HiOutlineMail } from 'react-icons/hi'; -import DataAnalysis from '../components/pages/settings/DataAnalysis'; -import useAppStore from '../store/useStore'; -import SimpleBackdrop from '../components/global/LoadingBackdrop'; -import { useRouter } from 'next/router'; -import { StorageService } from '../services/StorageService'; -import { IUser } from '../utils/types'; - -function Settings(): JSX.Element { - const { - isLoading, - userInfo, - getUserInfo, - changeEmail, - getUserGuildInfo, - fetchGuildChannels, - getGuilds, - } = useAppStore(); - - const [emailAddress, setEmailAddress] = useState(''); - const [isEmailUpdated, setEmailUpdated] = useState(false); - - const router = useRouter(); - - useEffect(() => { - const user = StorageService.readLocalStorage('user'); - if (user) { - const { token } = user; - if (!token.accessToken) { - router.replace('/tryNow'); - } - } else { - router.replace('/tryNow'); - } - }, []); - - useEffect(() => { - fetchEmail(); - const intervalId = setInterval(() => { - getGuilds(); - }, 5000); - - // Clean up the interval when the component unmounts - return () => clearInterval(intervalId); - }, []); - - const fetchEmail = async () => { - await getUserInfo().then((_res: any) => { - setEmailAddress(_res?.email); - }); - }; - - if (typeof window !== 'undefined') { - useEffect(() => { - if (Object.keys(router?.query).length > 0 && router.query.isSuccessful) { - const { guildId } = router?.query; - fetchGuildChannels(guildId); - getUserGuildInfo(guildId); - } - }, [router]); - } - - useEffect(() => { - const user = StorageService.readLocalStorage('user'); - - if (user) { - const { guildId } = user.guild; - if (guildId) { - fetchGuildChannels(guildId); - getUserGuildInfo(guildId); - } - } - }, []); - - const updateEmailAddress = () => { - changeEmail(emailAddress).then((res: any) => { - setEmailUpdated(true); - }); - }; - - const CommunityItems = [ - { - title: 'Change date period for data analysis', - icon: , - detailsComponent: , - id: '1', - }, - { - title: 'Change your imported channels', - icon: , - detailsComponent: , - id: '2', - }, - { - title: 'Integration', - icon: , - detailsComponent: , - id: '3', - }, - ]; - - const personalItems = [ - { - title: 'Email', - icon: , - detailsComponent: ( -
- setEmailAddress(e.target.value)} - /> - : ''} - disabled={emailAddress === userInfo.email} - label={isEmailUpdated ? 'Email saved' : 'Save email'} - onClick={() => updateEmailAddress()} - /> -
- ), - id: '1', - }, - ]; - - return ( - <> - - {isLoading ? ( - - ) : ( -
- -

Settings

- - -
-
- )} - - ); -} - -Settings.pageLayout = defaultLayout; - -export default Settings; diff --git a/src/pages/statistics.tsx b/src/pages/statistics.tsx index 27eef6d0..7abcd6a5 100644 --- a/src/pages/statistics.tsx +++ b/src/pages/statistics.tsx @@ -8,8 +8,6 @@ import InteractionsSection from '../components/pages/statistics/InteractionsSect import InactiveMembers from '../components/pages/statistics/InactiveMembers'; import SimpleBackdrop from '../components/global/LoadingBackdrop'; import { defaultLayout } from '../layouts/defaultLayout'; -import { IUser } from '../utils/types'; -import { StorageService } from '../services/StorageService'; import useAppStore from '../store/useStore'; import SEO from '../components/global/SEO'; import { Box } from '@mui/material'; @@ -17,9 +15,14 @@ import Link from '../components/global/Link'; import { AiOutlineLeft } from 'react-icons/ai'; import Onboarding from '../components/pages/statistics/Onboarding'; import { transformToMidnightUTC } from '../helpers/momentHelper'; +import { useToken } from '../context/TokenContext'; +import EmptyState from '../components/global/EmptyState'; +import emptyState from '../assets/svg/empty-state.svg'; +import Image from 'next/image'; const Statistics = () => { - const user = StorageService.readLocalStorage('user'); + const { community } = useToken(); + const platformId = community?.platforms[0]?.id; const [loading, setLoading] = useState(true); const [activeMemberDate, setActiveMemberDate] = useState(1); @@ -47,12 +50,10 @@ const Statistics = () => { useEffect(() => { const fetchData = async () => { try { - if (!user) { + if (!platformId) { return; } - const { guild } = user; - setLoading(true); if (activeTab === '1') { @@ -63,17 +64,17 @@ const Statistics = () => { ); await fetchActiveMembers( - guild.guildId, + platformId, activeDateRange[0], activeDateRange[1] ); await fetchInteractions( - guild.guildId, + platformId, activeIntegrationDateRange[0], activeIntegrationDateRange[1] ); await fetchOnboardingMembers( - guild.guildId, + platformId, onBoardingMemberDateRange[0], onBoardingMemberDateRange[1] ); @@ -82,12 +83,12 @@ const Statistics = () => { const inactiveMemberDateRange = getDateRange(inactiveMembersDate); await fetchDisengagedMembers( - guild.guildId, + platformId, disengagedDateRange[0], disengagedDateRange[1] ); await fetchInactiveMembers( - guild.guildId, + platformId, inactiveMemberDateRange[0], inactiveMemberDateRange[1] ); @@ -96,6 +97,8 @@ const Statistics = () => { setLoading(false); } catch (error) { setLoading(false); + } finally { + setLoading(false); } }; @@ -103,72 +106,65 @@ const Statistics = () => { }, [activeTab]); useEffect(() => { - if (!user) { + if (!platformId) { return; } - const { guild } = user; const activeDateRange = getDateRange(activeMemberDate); - fetchActiveMembers(guild.guildId, activeDateRange[0], activeDateRange[1]); + fetchActiveMembers(platformId, activeDateRange[0], activeDateRange[1]); }, [activeMemberDate]); useEffect(() => { - if (!user) { + if (!platformId) { return; } - const { guild } = user; const onBoardingMemberDateRange = getDateRange(onBoardingMemberDate); fetchOnboardingMembers( - guild.guildId, + platformId, onBoardingMemberDateRange[0], onBoardingMemberDateRange[1] ); }, [onBoardingMemberDate]); useEffect(() => { - if (!user) { + if (!platformId) { return; } - const { guild } = user; const activeIntegrationDateRange = getDateRange(activeInteractionDate); fetchInteractions( - guild.guildId, + platformId, activeIntegrationDateRange[0], activeIntegrationDateRange[1] ); }, [activeInteractionDate]); useEffect(() => { - if (!user) { + if (!platformId) { return; } - const { guild } = user; - const disengagedDateRange = getDateRange(disengagedMemberDate); fetchDisengagedMembers( - guild.guildId, + platformId, disengagedDateRange[0], disengagedDateRange[1] ); }, [disengagedMemberDate]); useEffect(() => { - if (!user) { + if (!platformId) { return; } - const { guild } = user; - const inactiveMemberDateRange = getDateRange(inactiveMembersDate); fetchInactiveMembers( - guild.guildId, + platformId, inactiveMemberDateRange[0], inactiveMemberDateRange[1] ); @@ -229,6 +225,15 @@ const Statistics = () => { setInactiveMembersDate(dateRangeType); }; + if (!community || community?.platforms?.length === 0) { + return ( + <> + + } /> + + ); + } + if (loading) { return ; } diff --git a/src/pages/tryNow.tsx b/src/pages/tryNow.tsx deleted file mode 100644 index ed0f3039..00000000 --- a/src/pages/tryNow.tsx +++ /dev/null @@ -1,845 +0,0 @@ -import * as React from 'react'; -import { styled } from '@mui/material/styles'; -import Stepper from '@mui/material/Stepper'; -import Step from '@mui/material/Step'; -import StepLabel from '@mui/material/StepLabel'; -import StepConnector, { - stepConnectorClasses, -} from '@mui/material/StepConnector'; -import { StepIconProps } from '@mui/material/StepIcon'; -import { IoClose } from 'react-icons/io5'; -import { BiError } from 'react-icons/bi'; -import { - Accordion, - AccordionDetails, - AccordionSummary, - Dialog, - Button, - Checkbox, - FormControlLabel, - TextField, -} from '@mui/material'; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; -import SEO from '../components/global/SEO'; -import clsx from 'clsx'; -import CustomButton from '../components/global/CustomButton'; -import useAppStore from '../store/useStore'; -import ChannelList from '../components/pages/login/ChannelList'; -import moment from 'moment'; -import SimpleBackdrop from '../components/global/LoadingBackdrop'; -import tclogo from '../assets/svg/tc-logo.svg'; -import Image from 'next/image'; -import { StorageService } from '../services/StorageService'; -import { FaDiscord } from 'react-icons/fa'; -import { FiRefreshCcw } from 'react-icons/fi'; -import Loading from '../components/global/Loading'; -import { MdExpandMore } from 'react-icons/md'; -import { IGuildChannels, ISubChannels, IUser } from '../utils/types'; -import * as amplitude from '@amplitude/analytics-browser'; -import { - setAmplitudeUserIdFromToken, - trackAmplitudeEvent, -} from '../helpers/amplitudeHelper'; - -const ColorlibConnector = styled(StepConnector)(() => ({ - [`&.${stepConnectorClasses.alternativeLabel}`]: { - top: 22, - }, - [`&.${stepConnectorClasses.active}`]: { - [`& .${stepConnectorClasses.line}`]: { - backgroundColor: '#804EE1', - }, - }, - [`&.${stepConnectorClasses.completed}`]: { - [`& .${stepConnectorClasses.line}`]: { - backgroundColor: '#804EE1', - }, - }, - [`& .${stepConnectorClasses.line}`]: { - height: 6, - border: 0, - backgroundColor: '#F5F5F5', - borderRadius: 1, - }, -})); - -const VerticalColorlibConnector = styled(StepConnector)(() => ({ - [`&.${stepConnectorClasses.active}`]: { - [`& .${stepConnectorClasses.line}`]: { - backgroundColor: '#804EE1', - }, - }, - [`&.${stepConnectorClasses.completed}`]: { - [`& .${stepConnectorClasses.line}`]: { - backgroundColor: '#804EE1', - }, - }, - [`& .${stepConnectorClasses.line}`]: { - marginTop: '-8px', - marginBottom: '-8px', - marginLeft: '8px', - minHeight: 'calc(24px + 1.5rem)', - borderLeftWidth: '6px', - borderColor: '#F5F5F5', - }, -})); - -const ColorlibStepIconRoot = styled('div')<{ - ownerState: { completed?: boolean; active?: boolean }; -}>(({ ownerState }) => ({ - backgroundColor: '#FFFFFF', - zIndex: 1, - color: '#222222', - fontWeight: 'bold', - width: 50, - height: 50, - fontSize: '27px', - display: 'flex', - borderRadius: '50%', - justifyContent: 'center', - alignItems: 'center', - boxShadow: '0px 5px 15px rgba(0, 0, 0, 0.2)', - ...(ownerState.active && { - border: 'solid 4px #804EE1', - }), - ...(ownerState.completed && { - backgroundColor: '#804EE1', - color: 'white', - }), -})); - -function ColorlibStepIcon(props: StepIconProps) { - const { active, completed, className } = props; - - const icons: { [index: string]: number } = { - 1: 1, - 2: 2, - 3: 3, - }; - - return ( - - {icons[String(props.icon)]} - - ); -} - -const steps = [ - <> - Connect your Discord community - , - <> - Select time period and channels you want to be analysed - , - <> - Begin data import - , -]; - -type dateItems = { - title: string; - icon?: JSX.Element; - value: any; -}; -const datePeriod: dateItems[] = [ - { - title: 'Last 35 days', - value: 1, - }, - { - title: '3M', - value: 2, - }, - { - title: '6M', - value: 3, - }, - { - title: '1Y', - value: 4, - }, -]; - -export default function TryNow() { - const router = useRouter(); - const [open, setOpen] = useState(false); - const [fullWidth, setFullWidth] = React.useState(true); - const [activeStep, setActiveStep] = useState(-1); - const [activePeriod, setActivePeriod] = useState(1); - const [emailAddress, setEmailAddress] = useState(''); - const [isTermsChecked, setTermsCheck] = useState(false); - const [guild, setGuild] = useState(''); - const [selectedPeriod, setSelectedPeriod] = useState(''); - const [channels, setChannels] = useState>([]); - const [selectedChannels, setSelectedChannels] = useState>([]); - const [tryNowState, setTryNowState] = useState<'active' | 'passive'>( - 'active' - ); - const { - isLoading, - signUp, - login, - loginWithDiscord, - fetchGuildChannels, - guildChannels, - changeEmail, - updateGuildById, - isRefetchLoading, - refetchGuildChannels, - } = useAppStore(); - - useEffect(() => { - const channels = guildChannels.map((guild: any, index: any) => { - const selected: Record = {}; - guild.subChannels.forEach((subChannel: any) => { - if (subChannel.canReadMessageHistoryAndViewChannel) { - selected[subChannel.channelId] = true; - } else { - selected[subChannel.channelId] = false; - } - }); - - return { ...guild, selected: selected }; - }); - - const subChannelsStatus = channels.map((channel: any) => { - return channel.selected; - }); - - const selectedChannelsStatus = Object.assign({}, ...subChannelsStatus); - let activeChannel: string[] = []; - for (const key in selectedChannelsStatus) { - if (selectedChannelsStatus[key]) { - activeChannel.push(key); - } - } - - const result = [].concat( - ...channels.map((channel: any) => { - return channel.subChannels - .filter((subChannel: ISubChannels) => { - if (activeChannel.includes(subChannel.channelId)) { - return subChannel; - } - }) - .map((filterdItem: any) => { - return { - channelId: filterdItem.channelId, - channelName: filterdItem.name, - }; - }); - }) - ); - setSelectedChannels(result); - - setChannels(channels); - }, [guildChannels]); - - const onChange = ( - channelId: string, - subChannelId: string, - status: boolean - ) => { - setChannels((preChannels) => { - return preChannels.map((preChannel) => { - if (preChannel.channelId !== channelId) return preChannel; - - const selected = preChannel.selected; - selected[subChannelId] = status; - - return { ...preChannel, selected }; - }); - }); - }; - const handleCheckAll = (guild: IGuildChannels, status: boolean) => { - const selectedGuild = channels.filter( - (channel: any) => channel.channelId === guild.channelId - )[0].channelId; - - setChannels((preChannels) => { - return preChannels.map((preChannel) => { - if (selectedGuild === preChannel.channelId) { - Object.keys(preChannel.selected).forEach((key: any) => { - preChannel.selected[key] = status; - }); - } - return preChannel; - }); - }); - }; - - if (typeof window !== 'undefined') { - useEffect(() => { - if (Object.keys(router?.query).length > 0 && router.query.statusCode) { - if (router.query.statusCode === '501') { - fetchGuildChannels(router.query.guildId); - setActiveStep(1); - const { - accessToken, - accessExp, - guildId, - guildName, - refreshExp, - refreshToken, - } = Object.assign({}, router.query); - setGuild(guildId); - loginWithDiscord({ - accessToken, - accessExp, - guildId, - guildName, - refreshExp, - refreshToken, - }); - setSelectedDatePeriod(1); - } else { - setTryNowState('passive'); - } - } - }, [router]); - } - const submitChannels = () => { - const subChannelsStatus = channels.map((channel: any) => { - return channel.selected; - }); - - const selectedChannelsStatus = Object.assign({}, ...subChannelsStatus); - let activeChannel: string[] = []; - for (const key in selectedChannelsStatus) { - if (selectedChannelsStatus[key]) { - activeChannel.push(key); - } - } - - const result = [].concat( - ...channels.map((channel: any) => { - return channel.subChannels - .filter((subChannel: any) => { - if ( - activeChannel.includes(subChannel.channelId) && - subChannel.canReadMessageHistoryAndViewChannel - ) { - return subChannel; - } - }) - .map((filterdItem: any) => { - return { - channelId: filterdItem.channelId, - channelName: filterdItem.name, - }; - }); - }) - ); - setSelectedChannels(result); - setOpen(false); - }; - - const handleClose = () => { - setOpen(false); - }; - - const refetchChannels = () => { - refetchGuildChannels(router.query.guildId); - }; - - const updateGuild = () => { - try { - updateGuildById(guild, selectedPeriod, selectedChannels).then( - (res: any) => { - if (emailAddress && emailAddress != '') { - changeEmail(emailAddress).then((_res: any) => { - setActiveStep(2); - }); - } else { - setActiveStep(2); - } - } - ); - } catch (error) {} - }; - - const redirectToSettings = () => { - const user = router.query; - StorageService.writeLocalStorage('user', { - guild: { - guildId: user.guildId, - guildName: user.guildName, - }, - token: { - accessToken: user.accessToken, - accessExp: user.accessExp, - refreshToken: user.refreshToken, - refreshExp: user.refreshExp, - }, - }); - router.push('/settings'); - }; - - const setSelectedDatePeriod = (dateRangeType: number | string) => { - let dateTime = ''; - switch (dateRangeType) { - case 1: - setActivePeriod(dateRangeType); - dateTime = moment() - .subtract('35', 'days') - .format('YYYY-MM-DDTHH:mm:ss[Z]'); - break; - case 2: - setActivePeriod(dateRangeType); - dateTime = moment() - .subtract('3', 'months') - .format('YYYY-MM-DDTHH:mm:ss[Z]'); - break; - case 3: - setActivePeriod(dateRangeType); - dateTime = moment() - .subtract('6', 'months') - .format('YYYY-MM-DDTHH:mm:ss[Z]'); - break; - case 4: - setActivePeriod(dateRangeType); - dateTime = moment() - .subtract('1', 'year') - .format('YYYY-MM-DDTHH:mm:ss[Z]'); - break; - default: - break; - } - setSelectedPeriod(dateTime); - - setActivePeriod(dateRangeType); - }; - - const handleRedirectToApp = () => { - const user: IUser | undefined = - StorageService.readLocalStorage('user'); - - setAmplitudeUserIdFromToken(); - - trackAmplitudeEvent({ - eventType: 'onboarding_successful_integration', - eventProperties: { - guild: user?.guild, - mail: emailAddress ? emailAddress : 'not exist', - }, - }); - router.push('/'); - }; - - return ( - <> - {isLoading ? ( - - ) : ( - <> - -
- - - -
- {tryNowState === 'active' ? ( -
-
- {activeStep === 0 || activeStep === -1 ? ( - <> -
-

- Welcome to TogetherCrew -

-

- Let’s connect your community. -

-
- - ) : ( - '' - )} -
-
- - ) : ( - - ) - } - > - {steps.map((label, index) => ( - - - {activeStep === 0 || activeStep === -1 ? label : ''} - - - ))} - -
- } - > - {steps.map((label, index) => ( - - - {activeStep === 0 || activeStep === -1 - ? label - : ''} - - - ))} - -
- {activeStep === 0 || activeStep === -1 ? ( - <> - setTermsCheck(e.target.checked)} - /> - } - label={ - - I understand and agree to the{' '} - - - Privacy Policy and Terms of Service. - - - - } - /> -
- signUp()} - classes={'bg-secondary text-white'} - /> -
- - ) : activeStep === 1 ? ( - <> -
-
-

- Choose date period for data analysis -

-

- You will be able to change date period and - selected channels in the future. -

-
-
    - {datePeriod.length > 0 - ? datePeriod.map((el) => ( -
  • - setSelectedDatePeriod(el.value) - } - > - {el.icon ? el.icon : ''} -
    {el.title}
    -
  • - )) - : ''} -
-
-
-
-

- Confirm your imported channels -

-

- Selected channels: - {selectedChannels.length}{' '} - { - setOpen(true); - }} - > - Show Channels - -

-
-
-

Type your email

- setEmailAddress(e.target.value)} - /> -
-
- updateGuild()} - label="Continue" - disabled={!activeStep} - /> -
-
- - ) : ( - <> -
-

- {"Perfect, you're all set!"} -

-

- Data import just started. It might take up to 6 - hours to finish. Once it is done we will send you a{' '} - message on Discord. -

- handleRedirectToApp()} - label="I Understand" - disabled={!activeStep} - /> -
-

- While you are waiting, read our research about{' '} - - {' '} - - Community Health. - - -

- - )} -
-
-
- {tryNowState === 'active' && - (activeStep === -1 || activeStep === 0) ? ( -
- - Already connected?{' '} - - Log in - -
- ) : ( - '' - )} -
- ) : ( -
-
-
-
- -
-

- Please, disconnect your
{' '} - community first -

- - There is one Discord community under your email already. If - you want to add a new community, please disconnect the - current community first. Go to the Settings section - and choose Disconnect option. - - { - redirectToSettings(); - }} - /> -
-
-
- )} - -
-
-
-

- Import activities from channels -

- -
-

- Select channels to import activity in this workspace. Please - give Together Crew access to all selected private channels by - updating the channels permissions in Discord. Discord - permission will affect the channels the bot can see. -

-
-
- {isRefetchLoading ? ( - - ) : ( -
-
- } - size="large" - variant="outlined" - onClick={refetchChannels} - /> -
- {channels && channels.length > 0 - ? channels.map((guild: IGuildChannels, index: any) => { - return ( -
- -
- ); - }) - : ''} -
- )} -
- - - } - > -

- How to give access to the channel you want to import? -

-
- -
-
    -
  1. - Navigate to the channel you want to import on{' '} - - Discord - -
  2. -
  3. - Go to the settings for that specific channel (select the - wheel on the right of the channel name) -
  4. -
  5. - Select Permissions (left sidebar), and then in - the middle of the screen check{' '} - Advanced permissions -
  6. -
  7. - With the TogetherCrew Bot selected, under - Advanced Permissions, make sure that [View channel] and - [Read message history] are marked as [✓] -
  8. -
  9. - Select the plus sign to the right of Roles/Members and - under members select TogetherCrew bot -
  10. -
  11. - Click on the Refresh List button on this window - and select the new channels -
  12. -
-
-
-
-
- {' '} -
-
-
- - )} - - ); -} diff --git a/src/services/StorageService.ts b/src/services/StorageService.ts index 97563047..67ece208 100644 --- a/src/services/StorageService.ts +++ b/src/services/StorageService.ts @@ -33,4 +33,29 @@ export class StorageService { public static removeLocalStorage(key: string): void { localStorage.removeItem(STORAGE_PREFIX + key); } + public static updateLocalStorageWithObject( + key: string, + newObjectKey: string, + newObject: string | Record + ): void { + const currentObj = this.readLocalStorage(key); + + if (!currentObj || typeof currentObj !== 'object') { + console.error('Current value is not an object, or it does not exist'); + return; + } + + if (typeof newObject === 'string') { + (currentObj as any)[newObjectKey] = newObject; + } else if (typeof newObject === 'object' && !Array.isArray(newObject)) { + (currentObj as any)[newObjectKey] = newObject; + } else { + console.error( + 'newObject should be an object, a string, and not an array.' + ); + return; + } + + this.writeLocalStorage(key, currentObj); + } } diff --git a/src/store/slices/breakdownsSlice.ts b/src/store/slices/breakdownsSlice.ts index c9b416de..1b73f423 100644 --- a/src/store/slices/breakdownsSlice.ts +++ b/src/store/slices/breakdownsSlice.ts @@ -1,6 +1,7 @@ import { StateCreator } from 'zustand'; import { axiosInstance } from '../../axiosInstance'; import IBreakdown from '../types/IBreakdown'; +import { IRolesPayload } from '../../components/pages/statistics/memberBreakdowns/CustomTable'; const createBreakdownsSlice: StateCreator = (set, get) => ({ isActiveMembersBreakdownLoading: false, @@ -9,9 +10,9 @@ const createBreakdownsSlice: StateCreator = (set, get) => ({ isRolesLoading: false, roles: [], getActiveMemberCompositionTable: async ( - guild_id: string, + platformId: string, activityComposition: string[], - roles: string[], + roles: IRolesPayload, username?: string, sortBy?: string, page?: number, @@ -39,25 +40,21 @@ const createBreakdownsSlice: StateCreator = (set, get) => ({ params.append('activityComposition', value); }); - requestData.roles.forEach((value) => { - params.append('roles', value); - }); - if (username) { params.append('ngu', username); } - const url = `/member-activity/${guild_id}/active-members-composition-table?${params.toString()}`; + const url = `/member-activity/${platformId}/active-members-composition-table?${params.toString()}`; - const { data } = await axiosInstance.get(url); + const { data } = await axiosInstance.post(url, roles); return data; } catch (error) {} }, getOnboardingMemberCompositionTable: async ( - guild_id: string, + platformId: string, activityComposition: string[], - roles: string[], + roles: IRolesPayload, username?: string, sortBy?: string, page?: number, @@ -85,25 +82,21 @@ const createBreakdownsSlice: StateCreator = (set, get) => ({ params.append('activityComposition', value); }); - requestData.roles.forEach((value) => { - params.append('roles', value); - }); - if (username) { params.append('ngu', username); } - const url = `/member-activity/${guild_id}/active-members-onboarding-table?${params.toString()}`; + const url = `/member-activity/${platformId}/active-members-onboarding-table?${params.toString()}`; - const { data } = await axiosInstance.get(url); + const { data } = await axiosInstance.post(url, roles); return data; } catch (error) {} }, getDisengagedMembersCompositionTable: async ( - guild_id: string, + platformId: string, activityComposition: string[], - roles: string[], + roles: IRolesPayload, username?: string, sortBy?: string, page?: number, @@ -131,33 +124,17 @@ const createBreakdownsSlice: StateCreator = (set, get) => ({ params.append('activityComposition', value); }); - requestData.roles.forEach((value) => { - params.append('roles', value); - }); - if (username) { params.append('ngu', username); } - const url = `/member-activity/${guild_id}/disengaged-members-composition-table?${params.toString()}`; + const url = `/member-activity/${platformId}/disengaged-members-composition-table?${params.toString()}`; - const { data } = await axiosInstance.get(url); + const { data } = await axiosInstance.post(url, roles); return data; } catch (error) {} }, - getRoles: async (guild_id: string) => { - try { - set(() => ({ isRolesLoading: true })); - - const { data } = await axiosInstance.get(`/guilds/${guild_id}/roles`); - - set(() => ({ roles: data, isRolesLoading: false })); - return data; - } catch (error) { - set(() => ({ isRolesLoading: false })); - } - }, }); export default createBreakdownsSlice; diff --git a/src/store/slices/centricSlice.ts b/src/store/slices/centricSlice.ts new file mode 100644 index 00000000..8008900d --- /dev/null +++ b/src/store/slices/centricSlice.ts @@ -0,0 +1,79 @@ +import { StateCreator } from 'zustand'; +import ICentric, { + ICreateCommunitieProps, + IPatchCommunityProps, + IRetrieveCommunitiesProps, +} from '../types/ICentric'; +import { conf } from '../../configs'; +import { axiosInstance } from '../../axiosInstance'; + +const BASE_URL = conf.API_BASE_URL; + +const createCentricSlice: StateCreator = (set, get) => ({ + discordAuthorization: () => { + location.replace(`${BASE_URL}/auth/discord/authorize`); + }, + retrieveCommunities: async ({ + page, + limit, + sortBy, + name, + }: IRetrieveCommunitiesProps) => { + try { + const params = { + page, + limit, + sortBy, + ...(name ? { name } : {}), + }; + + const { data } = await axiosInstance.get(`/communities/`, { params }); + return data; + } catch (error) { + console.error('Failed to retrieve communities:', error); + } + }, + createNewCommunitie: async ({ + name, + avatarURL, + tcaAt, + }: ICreateCommunitieProps) => { + try { + const { data } = await axiosInstance.post('communities', { + name, + avatarURL, + tcaAt, + }); + + return data; + } catch (error) {} + }, + retrieveCommunityById: async (communityId: string) => { + try { + const { data } = await axiosInstance.get(`/communities/${communityId}`); + return data; + } catch (error) {} + }, + deleteCommunityById: async (communityId: string) => { + try { + const { data } = await axiosInstance.delete( + `/communities/${communityId}` + ); + return data; + } catch (error) {} + }, + patchCommunityById: async ({ + communityId, + ...updateData + }: IPatchCommunityProps) => { + try { + const { data } = await axiosInstance.patch( + `/communities/${communityId}`, + updateData + ); + return data; + } catch (error) {} + }, +}); + +export default createCentricSlice; diff --git a/src/store/slices/chartSlice.ts b/src/store/slices/chartSlice.ts index 0b87e7c5..ff7be95e 100644 --- a/src/store/slices/chartSlice.ts +++ b/src/store/slices/chartSlice.ts @@ -2,7 +2,7 @@ import { StateCreator } from 'zustand'; import { axiosInstance } from '../../axiosInstance'; import ICharts from '../types/ICharts'; -const createHeatmapSlice: StateCreator = (set, get) => ({ +const chartSlice: StateCreator = (set, get) => ({ isLoading: false, heatmapRecords: [], interactions: {}, @@ -17,7 +17,7 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ inactiveMembersLoading: false, onboardingMembersLoading: false, fetchHeatmapData: async ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string, @@ -26,7 +26,7 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ try { set(() => ({ isLoading: true })); const { data } = await axiosInstance.post( - `/heatmaps/${guild_id}/heatmap-chart`, + `/heatmaps/${platformId}/heatmap-chart`, { startDate, endDate, @@ -41,14 +41,14 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ } }, fetchInteractions: async ( - guild_id: string, + platformId: string, startDate: string, endDate: string ) => { try { set(() => ({ interactionsLoading: true })); const { data } = await axiosInstance.post( - `/heatmaps/${guild_id}/line-graph`, + `/heatmaps/${platformId}/line-graph`, { startDate, endDate, @@ -60,14 +60,14 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ } }, fetchActiveMembers: async ( - guild_id: string, + platformId: string, startDate: string, endDate: string ) => { try { set(() => ({ activeMembersLoading: true })); const { data } = await axiosInstance.post( - `/member-activity/${guild_id}/active-members-composition-line-graph`, + `/member-activity/${platformId}/active-members-composition-line-graph`, { startDate, endDate, @@ -79,14 +79,14 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ } }, fetchDisengagedMembers: async ( - guild_id: string, + platformId: string, startDate: string, endDate: string ) => { try { set(() => ({ disengagedMembersLoading: true })); const { data } = await axiosInstance.post( - `/member-activity/${guild_id}/disengaged-members-composition-line-graph`, + `/member-activity/${platformId}/disengaged-members-composition-line-graph`, { startDate, endDate, @@ -98,14 +98,14 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ } }, fetchInactiveMembers: async ( - guild_id: string, + platformId: string, startDate: string, endDate: string ) => { try { set(() => ({ inactiveMembersLoading: true })); const { data } = await axiosInstance.post( - `/member-activity/${guild_id}/inactive-members-line-graph `, + `/member-activity/${platformId}/inactive-members-line-graph `, { startDate, endDate, @@ -117,14 +117,14 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ } }, fetchOnboardingMembers: async ( - guild_id: string, + platformId: string, startDate: string, endDate: string ) => { try { set(() => ({ onboardingMembersLoading: true })); const { data } = await axiosInstance.post( - `/member-activity/${guild_id}/active-members-onboarding-line-graph `, + `/member-activity/${platformId}/active-members-onboarding-line-graph `, { startDate, endDate, @@ -135,11 +135,11 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ set(() => ({ onboardingMembersLoading: false })); } }, - getSelectedChannelsList: async (guild_id: string) => { + getSelectedChannelsList: async (platformId: string) => { try { set(() => ({ isLoading: true })); const { data } = await axiosInstance.get( - `/guilds/${guild_id}/selected-channels` + `/guilds/${platformId}/selected-channels` ); set({ selectedChannelsList: data, isLoading: false }); return data; @@ -149,4 +149,4 @@ const createHeatmapSlice: StateCreator = (set, get) => ({ }, }); -export default createHeatmapSlice; +export default chartSlice; diff --git a/src/store/slices/communityHealthSlice.ts b/src/store/slices/communityHealthSlice.ts index 21036868..6712d3c2 100644 --- a/src/store/slices/communityHealthSlice.ts +++ b/src/store/slices/communityHealthSlice.ts @@ -7,11 +7,11 @@ const createCommunityHealthSlice: StateCreator = ( get ) => ({ isLoading: false, - getFragmentation: async (guild_id: string) => { + getFragmentation: async (platformId: string) => { try { set(() => ({ isLoading: true })); const { data } = await axiosInstance.get( - `/member-activity/${guild_id}/fragmentation-score` + `/member-activity/${platformId}/fragmentation-score` ); set({ isLoading: false }); return data; @@ -19,11 +19,11 @@ const createCommunityHealthSlice: StateCreator = ( set(() => ({ isLoading: false })); } }, - getDecentralisation: async (guild_id: string) => { + getDecentralisation: async (platformId: string) => { try { set(() => ({ isLoading: true })); const { data } = await axiosInstance.get( - `/member-activity/${guild_id}/decentralisation-score` + `/member-activity/${platformId}/decentralisation-score` ); set({ isLoading: false }); return data; diff --git a/src/store/slices/memberInteractionSlice.ts b/src/store/slices/memberInteractionSlice.ts index 7c135ae9..dbc90553 100644 --- a/src/store/slices/memberInteractionSlice.ts +++ b/src/store/slices/memberInteractionSlice.ts @@ -5,11 +5,11 @@ import IMemberInteraction from '../types/IMemberInteraction'; const createHeatmapSlice: StateCreator = (set, get) => ({ isLoading: false, memberInteractionRecords: [], - getMemberInteraction: async (guild_id: string) => { + getMemberInteraction: async (platformId: string) => { try { set(() => ({ isLoading: true })); const { data } = await axiosInstance.post( - `/member-activity/${guild_id}/members-interactions-network-graph` + `/member-activity/${platformId}/members-interactions-network-graph` ); set({ memberInteractionRecords: [...data], isLoading: false }); return data; diff --git a/src/store/slices/platformSlice.ts b/src/store/slices/platformSlice.ts new file mode 100644 index 00000000..164e3919 --- /dev/null +++ b/src/store/slices/platformSlice.ts @@ -0,0 +1,112 @@ +import { StateCreator } from 'zustand'; + +import { axiosInstance } from '../../axiosInstance'; +import IPlatfrom, { + IRetrievePlatformsProps, + IRetrivePlatformRolesOrChannels, + IPatchPlatformInput, +} from '../types/IPlatform'; +import { conf } from '../../configs'; +import { IPlatformProps } from '../../utils/interfaces'; + +const BASE_URL = conf.API_BASE_URL; + +const createPlatfromSlice: StateCreator = (set, get) => ({ + connectedPlatforms: [], + connectNewPlatform: (platfromType) => { + try { + location.replace(`${BASE_URL}/platforms/connect/${platfromType}`); + } catch (error) {} + }, + retrievePlatforms: async ({ + page, + limit, + sortBy, + name, + community, + }: IRetrievePlatformsProps) => { + try { + const params = { + page, + limit, + sortBy, + ...(name ? { name } : {}), + ...(community ? { community } : {}), + }; + + const { data } = await axiosInstance.get(`/platforms/`, { params }); + set({ connectedPlatforms: [...data.results] }); + return data; + } catch (error) { + console.error('Failed to retrieve communities:', error); + } + }, + createNewPlatform: async ({ name, community, metadata }: IPlatformProps) => { + try { + const { data } = await axiosInstance.post('/platforms', { + name, + community, + metadata, + }); + return data; + } catch (error) {} + }, + retrievePlatformById: async (id: string) => { + try { + const { data } = await axiosInstance.get(`/platforms/${id}`); + return data; + } catch (error) {} + }, + deletePlatform: async ({ id, deleteType }) => { + try { + const { data } = await axiosInstance.delete(`/platforms/${id}`, { + data: { + deleteType, + }, + }); + return data; + } catch (error) {} + }, + retrievePlatformProperties: async ({ + platformId, + property = 'channel', + name, + sortBy, + page, + limit, + }: IRetrivePlatformRolesOrChannels) => { + try { + const params = new URLSearchParams(); + + params.append('property', property); + + // Only append sortBy if it's not undefined + if (sortBy !== undefined) { + params.append('sortBy', sortBy); + } + + if (name) params.append('name', name); + + if (page !== undefined) { + params.append('page', page.toString()); + } + if (limit !== undefined) { + params.append('limit', limit.toString()); + } + + const url = `/platforms/${platformId}/properties?${params.toString()}`; + const { data } = await axiosInstance.post(url); + return data; + } catch (error) {} + }, + patchPlatformById: async ({ id, metadata }: IPatchPlatformInput) => { + try { + const { data } = await axiosInstance.patch(`/platforms/${id}`, { + metadata, + }); + return data; + } catch (error) {} + }, +}); + +export default createPlatfromSlice; diff --git a/src/store/slices/settingSlice.ts b/src/store/slices/settingSlice.ts index e60fc206..175ede5d 100644 --- a/src/store/slices/settingSlice.ts +++ b/src/store/slices/settingSlice.ts @@ -25,13 +25,10 @@ const createSettingSlice: StateCreator = (set, get) => ({ }, getUserInfo: async () => { try { - set(() => ({ isLoading: true })); const { data } = await axiosInstance.get('/users/@me'); - set({ userInfo: data, isLoading: false }); + set({ userInfo: data }); return data; - } catch (error) { - set(() => ({ isLoading: false })); - } + } catch (error) {} }, getGuildInfoByDiscord: async (guildId) => { try { diff --git a/src/store/slices/twitterSlice.ts b/src/store/slices/twitterSlice.ts new file mode 100644 index 00000000..24f718f9 --- /dev/null +++ b/src/store/slices/twitterSlice.ts @@ -0,0 +1,55 @@ +import { StateCreator } from 'zustand'; +import { axiosInstance } from '../../axiosInstance'; +import ITwitter from '../types/ITwitter'; +import { conf } from '../../configs'; + +const BASE_URL = conf.API_BASE_URL; + +const createTwitterSlice: StateCreator = (set, get) => ({ + isLoading: false, + authorizeTwitter: async (discordId: string) => { + try { + location.replace(`${BASE_URL}/auth/twitter/login/user/${discordId}`); + } catch (error) { + console.error('Error in intermediary auth step:', error); + } + }, + disconnectTwitter: async () => { + try { + set(() => ({ isLoading: true })); + await axiosInstance.post(`twitter/disconnect`); + set(() => ({ isLoading: false })); + } catch (error) {} + }, + refreshTwitterMetrics: async () => { + try { + await axiosInstance.post(`/twitter/metrics/refresh`); + } catch (error) {} + }, + twitterActivityAccount: async () => { + try { + const { data } = await axiosInstance.get(`/twitter/metrics/activity`); + return data; + } catch (error) {} + }, + twitterAudienceAccount: async () => { + try { + const { data } = await axiosInstance.get(`/twitter/metrics/audience`); + return data; + } catch (error) {} + }, + twitterEngagementAccount: async () => { + try { + const { data } = await axiosInstance.get(`/twitter/metrics/engagement`); + return data; + } catch (error) {} + }, + twitterAccount: async () => { + try { + const { data } = await axiosInstance.get(`/twitter/metrics/account`); + return data; + } catch (error) {} + }, +}); + +export default createTwitterSlice; diff --git a/src/store/slices/userSlice.ts b/src/store/slices/userSlice.ts new file mode 100644 index 00000000..3dd9efda --- /dev/null +++ b/src/store/slices/userSlice.ts @@ -0,0 +1,14 @@ +import { StateCreator } from 'zustand'; +import { axiosInstance } from '../../axiosInstance'; +import IUser, { patchUserPayload } from '../types/IUser'; + +const createUserSlice: StateCreator = (set, get) => ({ + patchUser: async (payload: patchUserPayload) => { + try { + const { data } = await axiosInstance.patch(`/users/@me`, { ...payload }); + return data; + } catch (error) {} + }, +}); + +export default createUserSlice; diff --git a/src/store/types/IBreakdown.ts b/src/store/types/IBreakdown.ts index e008bfd5..6932e793 100644 --- a/src/store/types/IBreakdown.ts +++ b/src/store/types/IBreakdown.ts @@ -1,3 +1,5 @@ +import { IRolesPayload } from '../../components/pages/statistics/memberBreakdowns/CustomTable'; + export default interface IBreakdown { isActiveMembersBreakdownLoading: boolean; isOnboardingMembersBreakdownLoading: boolean; @@ -5,31 +7,30 @@ export default interface IBreakdown { isRolesLoading: boolean; roles: any[]; getActiveMemberCompositionTable: ( - guild_id: string, + platformId: string, activityComposition: string[], - roles: string[], + roles: IRolesPayload, username?: string, sortBy?: string, page?: number, limit?: number ) => any; getOnboardingMemberCompositionTable: ( - guild_id: string, + platformId: string, activityComposition: string[], - roles: string[], + roles: IRolesPayload, username?: string, sortBy?: string, page?: number, limit?: number ) => any; getDisengagedMembersCompositionTable: ( - guild_id: string, + platformId: string, activityComposition: string[], - roles: string[], + roles: IRolesPayload, username?: string, sortBy?: string, page?: number, limit?: number ) => any; - getRoles: (guild_id: string) => any; } diff --git a/src/store/types/ICentric.ts b/src/store/types/ICentric.ts new file mode 100644 index 00000000..f8515c84 --- /dev/null +++ b/src/store/types/ICentric.ts @@ -0,0 +1,41 @@ +export interface IRetrieveCommunitiesProps { + page: number; + limit: number; + sortBy?: string; + name?: string; +} +export interface ICreateCommunitieProps { + name: string; + avatarURL?: string; + tcaAt: string; +} + +export interface IPatchCommunityProps { + communityId: string; + name?: string; + avatarURL?: string; + tcaAt?: string; +} + +export default interface ICentric { + discordAuthorization: () => void; + retrieveCommunities: ({ + page, + limit, + sortBy, + name, + }: IRetrieveCommunitiesProps) => void; + createNewCommunitie: ({ + name, + avatarURL, + tcaAt, + }: ICreateCommunitieProps) => void; + retrieveCommunityById: (communityId: string) => void; + deleteCommunityById: (communityId: string) => void; + patchCommunityById: ({ + communityId, + name, + avatarURL, + tcaAt, + }: IPatchCommunityProps) => void; +} diff --git a/src/store/types/ICharts.ts b/src/store/types/ICharts.ts index b4e6f2e5..bde0757f 100644 --- a/src/store/types/ICharts.ts +++ b/src/store/types/ICharts.ts @@ -13,41 +13,41 @@ export default interface ICharts { onboardingMembers: {}; selectedChannelsList: any[]; fetchHeatmapData: ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string, channelIds: string[] ) => any; fetchInteractions: ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string ) => any; fetchActiveMembers: ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string ) => any; fetchDisengagedMembers: ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string ) => any; fetchInactiveMembers: ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string ) => any; fetchOnboardingMembers: ( - guild_id: string, + platformId: string, startDate: string, endDate: string, timeZone: string ) => any; - getSelectedChannelsList: (guild_id: string) => any; + getSelectedChannelsList: (platformId: string) => any; } diff --git a/src/store/types/ICommunityHealth.ts b/src/store/types/ICommunityHealth.ts index 85ee9e15..0f8aebd7 100644 --- a/src/store/types/ICommunityHealth.ts +++ b/src/store/types/ICommunityHealth.ts @@ -1,5 +1,5 @@ export default interface ICommunityHealth { isLoading: boolean; - getFragmentation: (guild_id: string) => any; - getDecentralisation: (guild_id: string) => any; + getFragmentation: (platformId: string) => any; + getDecentralisation: (platformId: string) => any; } diff --git a/src/store/types/IMemberInteraction.ts b/src/store/types/IMemberInteraction.ts index cf80b0bf..49d2e275 100644 --- a/src/store/types/IMemberInteraction.ts +++ b/src/store/types/IMemberInteraction.ts @@ -1,5 +1,5 @@ export default interface IMemberInteraction { isLoading: boolean; memberInteractionRecords: any[]; - getMemberInteraction: (guild_id: string) => any; + getMemberInteraction: (platformId: string) => any; } diff --git a/src/store/types/IPlatform.ts b/src/store/types/IPlatform.ts new file mode 100644 index 00000000..6ad1e5f5 --- /dev/null +++ b/src/store/types/IPlatform.ts @@ -0,0 +1,59 @@ +import { IPlatformProps } from '../../utils/interfaces'; + +export interface IRetrievePlatformsProps { + page: number; + limit: number; + sortBy?: string; + name?: string; + community: string; +} + +export interface IRetrivePlatformRolesOrChannels { + page?: number; + limit?: number; + sortBy?: string; + name?: string; + platformId: string; + property: 'channel' | 'role'; +} + +export interface IDeletePlatformProps { + id: string; + deleteType: 'hard' | 'soft'; +} + +export interface IPatchPlatformInput { + id: string; + metadata: { + selectedChannels: string[]; + period: string; + analyzerStartedAt: string; + }; +} + +export default interface IPlatfrom { + connectedPlatforms: any[]; + connectNewPlatform: (platfromType: string) => void; + retrievePlatforms: ({ + page, + limit, + sortBy, + name, + community, + }: IRetrievePlatformsProps) => void; + createNewPlatform: ({ name, community, metadata }: IPlatformProps) => void; + retrievePlatformById: (id: string) => void; + deletePlatform: ({ id, deleteType }: IDeletePlatformProps) => void; + patchPlatformById: ({ + id, + metadata: { selectedChannels, period, analyzerStartedAt }, + }: IPatchPlatformInput) => void; + retrievePlatformProperties: ({ + platformId, + property, + name, + sortBy, + page, + limit, + }: IRetrivePlatformRolesOrChannels) => void; +} diff --git a/src/store/types/ISetting.ts b/src/store/types/ISetting.ts index 82ff978d..5de57107 100644 --- a/src/store/types/ISetting.ts +++ b/src/store/types/ISetting.ts @@ -11,6 +11,19 @@ export type IGuildInfo = { export type DISCONNECT_TYPE = 'soft' | 'hard'; +export interface IUserInfo { + discordId: string; + email: string; + verified: boolean; + avatar: string; + twitterConnectedAt: string; + twitterId: string; + twitterProfileImageUrl: string; + twitterUsername: string; + twitterIsInProgress: boolean; + id: string; +} + export default interface IGuildList extends IGuildInfo { isInProgress?: boolean; isDisconnected?: boolean; @@ -20,7 +33,7 @@ export default interface ISetting { isLoading: boolean; isRefetchLoading: boolean; guildInfo?: IGuildInfo | {}; - userInfo: {}; + userInfo: IUserInfo | {}; guildInfoByDiscord: {}; guilds: IGuildList[]; guildChannels: IGuildChannels[]; diff --git a/src/store/types/ITwitter.ts b/src/store/types/ITwitter.ts new file mode 100644 index 00000000..7bacc8cc --- /dev/null +++ b/src/store/types/ITwitter.ts @@ -0,0 +1,10 @@ +export default interface ITwitter { + isLoading: boolean; + authorizeTwitter: (discordId: string) => void; + disconnectTwitter: () => void; + refreshTwitterMetrics: () => void; + twitterActivityAccount: () => void; + twitterAudienceAccount: () => void; + twitterEngagementAccount: () => void; + twitterAccount: () => void; +} diff --git a/src/store/types/IUser.ts b/src/store/types/IUser.ts new file mode 100644 index 00000000..3777c62d --- /dev/null +++ b/src/store/types/IUser.ts @@ -0,0 +1,8 @@ +export interface patchUserPayload { + email?: string; + tcaAt: string; +} + +export default interface IUser { + patchUser: (payload: patchUserPayload) => void; +} diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 18401a43..63526092 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -5,6 +5,10 @@ import createSettingSlice from './slices/settingSlice'; import createBreakdownsSlice from './slices/breakdownsSlice'; import createMemberInteractionSlice from './slices/memberInteractionSlice'; import communityHealthSlice from './slices/communityHealthSlice'; +import twitterSlice from './slices/twitterSlice'; +import centricSlice from './slices/centricSlice'; +import platformSlice from './slices/platformSlice'; +import userSlice from './slices/userSlice'; const useAppStore = create()((...a) => ({ ...createAuthSlice(...a), @@ -13,6 +17,10 @@ const useAppStore = create()((...a) => ({ ...createBreakdownsSlice(...a), ...createMemberInteractionSlice(...a), ...communityHealthSlice(...a), + ...twitterSlice(...a), + ...centricSlice(...a), + ...platformSlice(...a), + ...userSlice(...a), })); export default useAppStore; diff --git a/src/styles/globals.css b/src/styles/globals.css index 90cdbcb3..a09541cc 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -57,6 +57,16 @@ body { line-height: 24px; } +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge, and Firefox */ +.scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + .css-1hv8oq8-MuiStepLabel-label.MuiStepLabel-alternativeLabel { font-size: 16px !important; line-height: 24px !important; diff --git a/src/utils/enums.ts b/src/utils/enums.ts new file mode 100644 index 00000000..957793c5 --- /dev/null +++ b/src/utils/enums.ts @@ -0,0 +1,17 @@ +export enum IntegrationPlatform { + Discord = 'Discord', + Twitter = 'Twitter', + Discourse = 'Discourse', + Telegram = 'Telegram', + Snapshot = 'Snapshot', +} + +export enum StatusCode { + DISCORD_AUTHORIZATION_SUCCESSFUL_FIRST_TIME = '1001', + REPEATED_DISCORD_AUTHORIZATION_ATTEMPT = '1002', + DISCORD_AUTHORIZATION_FAILURE = '1003', + DISCORD_AUTHORIZATION_FROM_SETTINGS = '1004', + DISCORD_AUTHORIZATION_FAILURE_FROM_SETTINGS = '1005', + TWITTER_AUTHORIZATION_SUCCESSFUL = '1006', + TWITTER_AUTHORIZATION_FAILURE = '1007', +} diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 829933ce..d717b1ba 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -27,6 +27,8 @@ export interface IRoles { roleId: string; color: number | string; name: string; + deletedAt: string; + id: number | string; } export interface IUserProfile { @@ -85,3 +87,86 @@ export interface ITrackEventParams { eventProperties?: Record; callback?: (result: { event: any; code: any; message: any }) => void; } + +export interface IActivity { + posts: number; + replies: number; + retweets: number; + likes: number; + mentions: number; +} + +export interface IAudience { + replies: number; + retweets: number; + likes: number; + mentions: number; +} + +export interface IEngagement { + hqla: number; + hqhe: number; + lqla: number; + lqhe: number; +} + +export interface IAccount { + follower: number; + engagement: number; +} + +export interface IDataTwitter { + activity: IActivity; + audience: IAudience; + engagement: IEngagement; + account: IAccount; +} + +export interface ICommunity { + id: string; + name: string; + platforms: string[]; + users: string[]; + avatarURL: string; +} + +export interface FetchedData { + limit: number; + page: number; + results: any[]; + totalPages: number; + totalResults: number; +} + +export interface IPlatformProps { + name: string; + community: string; + isInProgress: boolean; + connectedAt: string; + id: string; + disconnectedAt: string | null; + metadata: metaData; +} + +export interface ICommunityDiscordPlatfromProps { + id: string; + name: string; + metadata: { + id: string; + icon: string; + name: string; + selectedChannels: string[]; + period: string; + analyzerStartedAt: string; + }; + disconnectedAt: string | null; +} + +export interface metaData { + [key: string]: any; +} + +export interface IDiscordModifiedCommunity + extends Omit { + platforms: ICommunityDiscordPlatfromProps[]; +} diff --git a/src/utils/privateRoute.tsx b/src/utils/privateRoute.tsx index 42142d57..eddcb90a 100644 --- a/src/utils/privateRoute.tsx +++ b/src/utils/privateRoute.tsx @@ -1,26 +1,36 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { StorageService } from '../services/StorageService'; -import { IUser } from './types'; +import { useEffect, useState, useMemo } from 'react'; import { useRouter } from 'next/router'; import SimpleBackdrop from '../components/global/LoadingBackdrop'; +import { StorageService } from '../services/StorageService'; +import { IToken } from './types'; -export default function privateRoute({ children }: any) { - const [user, setUser] = useState(null); +export default function PrivateRoute({ + children, +}: { + children: React.ReactNode; +}) { + const [token, setToken] = useState(null); const router = useRouter(); + const isCentricRoute = useMemo( + () => router.pathname.startsWith('/centric'), + [router.pathname] + ); + + const isObjectNotEmpty = (obj: Record): boolean => { + return Object.keys(obj).length > 0; + }; useEffect(() => { - const user = StorageService.readLocalStorage('user'); - if (user) { - setUser(user); - const { token } = user; - if (!token.accessToken) { - router.replace('/tryNow'); + if (!isCentricRoute) { + const storedToken = StorageService.readLocalStorage('user'); + + if (storedToken && storedToken.accessToken) { + setToken(storedToken); + } else { + router.replace('/centric'); } - } else { - router.replace('/tryNow'); } - }, []); + }, [isCentricRoute, router]); - return <>{!user ? : children}; + return <>{!token && !isCentricRoute ? : children}; } diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 5f34779f..38e0d8b1 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -3,23 +3,48 @@ import { createTheme } from '@mui/material/styles'; export const theme = createTheme({ palette: { primary: { - main: '#35B9B7', + main: '#804EE1', }, }, typography: { - button: { - textTransform: 'none', - }, + fontFamily: 'inherit', + fontWeightBold: '500', + fontWeightExtraBold: '700', }, components: { MuiButton: { styleOverrides: { + sizeMedium: { + width: '15rem', + padding: '0.5rem', + }, root: { + textTransform: 'none', borderRadius: '4px', color: '#804EE1', + '&.Mui-disabled': { opacity: 0.7, }, + '@media (max-width:1023px)': { + width: '100%', + }, + }, + contained: { + background: '#804EE1 !important', + color: 'white', + '&.Mui-disabled': { + color: 'white', + }, + }, + outlined: { + background: 'transparent', + border: '1px solid #222222', + color: '#222222', + '&:hover': { + background: '#F5F5F5', + border: '1px solid #222222', + }, }, }, }, @@ -33,9 +58,13 @@ export const theme = createTheme({ MuiTextField: { styleOverrides: { root: { + minWidth: '25rem', '& label.Mui-focused': { color: '#804EE1', }, + '@media (max-width:1023px)': { + minWidth: '100%', + }, }, }, }, @@ -54,7 +83,6 @@ export const theme = createTheme({ MuiAlert: { styleOverrides: { root: { - padding: '6px 9rem 6px 14rem', borderRadius: '0px', position: 'sticky', top: '0', @@ -72,6 +100,7 @@ export const theme = createTheme({ styleOverrides: { root: { borderBottom: 'none', + textTransform: 'none', }, indicator: { backgroundColor: 'transparent', @@ -82,6 +111,7 @@ export const theme = createTheme({ MuiTab: { styleOverrides: { root: { + textTransform: 'none', borderRadius: '10px 10px 0 0', padding: '8px 24px', width: '214px', @@ -107,7 +137,15 @@ export const theme = createTheme({ }, }, }); +declare module '@mui/material/styles/createTypography' { + interface TypographyOptions { + fontWeightExtraBold?: string; + } + interface Typography { + fontWeightExtraBold: string; + } +} declare module '@mui/material/styles' { interface Palette { neutral: Palette['primary']; diff --git a/src/utils/types.ts b/src/utils/types.ts index 5fa543cd..c5c59ae7 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,7 +1,7 @@ export type IToken = { - readonly accessExp: string; + readonly accessExp?: string; readonly accessToken: string; - readonly refreshExp: string; + readonly refreshExp?: string; readonly refreshToken: string; }; @@ -14,9 +14,19 @@ export interface callbackUrlParams extends IGuild, IToken { statusCode: number | string; } +export interface ITwitter { + twitterConnectedAt: string; + twitterId: string; + twitterProfileImageUrl: string; + twitterUsername: string; + lastUpdatedMetrics: string; + twitterIsInProgress: boolean; +} + export type IUser = { token: IToken; guild: IGuild; + twitter?: ITwitter; }; export type IGuildChannels = { diff --git a/tailwind.config.js b/tailwind.config.js index d5e68aab..26f64902 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -20,13 +20,7 @@ const colors = { 500: '#225262', }, info: { - DEFAULT: '#4368F1', - 50: '#F7FFFE', - 100: '#D0FBF8', - 200: '#A7F3F0', - 300: '#92DAD6', - 400: '#7DC0BD', - 500: '#39C2C0', + DEFAULT: '#1DA1F2', 600: '#313671', }, error: { @@ -80,7 +74,8 @@ const colors = { 'purple-dark': '#673FB5', 'purple-darker': '#35205E', 'gray-subtitle':'#767676', - orange:'#FF8022' + orange:'#FF8022', + 'gray-border-box':'#AAAAAA', }; const backgroundImage = {