diff --git a/package-lock.json b/package-lock.json index 484624c..88ffd26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@mui/material": "^6.1.1", "@mui/styled-engine-sc": "^6.1.1", "axios": "^1.7.9", + "framer-motion": "^11.15.0", "hugeicons-react": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -2822,6 +2823,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.15.0.tgz", + "integrity": "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.14.3", + "motion-utils": "^11.14.3", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -3685,6 +3713,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", + "integrity": "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==", + "license": "MIT" + }, + "node_modules/motion-utils": { + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", + "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" diff --git a/package.json b/package.json index d43c480..77edd1e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@mui/material": "^6.1.1", "@mui/styled-engine-sc": "^6.1.1", "axios": "^1.7.9", + "framer-motion": "^11.15.0", "hugeicons-react": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/assets/data/revenueData.json b/src/assets/data/revenueData.json new file mode 100644 index 0000000..2dbe509 --- /dev/null +++ b/src/assets/data/revenueData.json @@ -0,0 +1,4 @@ +{ + "totalRevenue": 100000, + "comparedToLastMonth": "+ 4.12 %" +} \ No newline at end of file diff --git a/src/assets/data/userData.json b/src/assets/data/userData.json new file mode 100644 index 0000000..03b10e1 --- /dev/null +++ b/src/assets/data/userData.json @@ -0,0 +1,5 @@ +{ + "totalUsers": 10000, + "activeUsers": 5000, + "unsubscribedUsers": 500 +} diff --git a/src/components/uiMainContentTitle/uiMainContentTitle.tsx b/src/components/uiMainContentTitle/uiMainContentTitle.tsx index 5c2675b..8880216 100644 --- a/src/components/uiMainContentTitle/uiMainContentTitle.tsx +++ b/src/components/uiMainContentTitle/uiMainContentTitle.tsx @@ -26,7 +26,7 @@ export const UiMainContentTitle: FunctionComponent = ( return ( - { title } + { title } { subTitle } ); diff --git a/src/features/overview/components/TopArtists.tsx b/src/features/overview/components/TopArtists.tsx new file mode 100644 index 0000000..9543553 --- /dev/null +++ b/src/features/overview/components/TopArtists.tsx @@ -0,0 +1,74 @@ +import CircularProgress from "@mui/material/CircularProgress"; +import { SubSectionCard } from "../../../pages/app/components/SubSectionCard"; +import { SubSectionLayout } from "../../../pages/app/components/SubSectionLayout"; +import { SubSectionListCard } from "../../../pages/app/components/SubSectionListCard"; +import useBillboardData from "../../../states/billboardData/hooks/useBillboardData"; +import { Artist } from "../../../states/billboardData/models/Artist"; + +/** + * Component to display the top artists section + */ +export default function TopArtists() { + const { + isTopArtistsError, + isTopArtistsLoading, + rankingDate, + topArtists + } = useBillboardData(); + + // Show loading state + if (isTopArtistsLoading) { + return ; + } + + // Show error state + if (isTopArtistsError ) { + return
Error loading top artists data
; + } + + // Ensure we have enough artists before rendering + const hasEnoughArtists = topArtists?.length >= 5; + + if (!hasEnoughArtists) { + return
Insufficient artist data
; + } + + return ( + , + ({ + imageUrl: artist?.image || "", + number: `${index + 2}`, + title: artist?.name || "N/A" + })) + } + />, + ({ + imageUrl: artist?.image || "", + number: `${index + 6}`, + title: artist?.name || "N/A" + })) + } + /> + ] } + /> + ); +} diff --git a/src/features/overview/components/TopSongs.tsx b/src/features/overview/components/TopSongs.tsx new file mode 100644 index 0000000..f567c26 --- /dev/null +++ b/src/features/overview/components/TopSongs.tsx @@ -0,0 +1,81 @@ +import CircularProgress from "@mui/material/CircularProgress"; +import { Fragment } from "react/jsx-runtime"; +import { SubSectionCard } from "../../../pages/app/components/SubSectionCard"; +import { SubSectionLayout } from "../../../pages/app/components/SubSectionLayout"; +import { SubSectionListCard } from "../../../pages/app/components/SubSectionListCard"; +import useBillboardData from "../../../states/billboardData/hooks/useBillboardData"; +import { Song } from "../../../states/billboardData/models/Song"; + +/** + * Component to display the top songs section + */ +export default function TopSongs() { + const { + isTopSongsLoading, + isTopSongsError, + topSongs, + songsRankingDate + } = useBillboardData(); + + // Show loading state + if (isTopSongsLoading) { + return ; + } + + // Show error state + if (isTopSongsError) { + return
Error loading top songs data
; + } + + // Ensure we have enough artists before rendering + const hasEnoughSongs = topSongs?.length >= 5; + + if (!hasEnoughSongs) { + return
Insufficient songs data
; + } + + return ( + + , + ({ + imageUrl: song?.image || "", + number: `${index + 2}`, + subtitle: song?.artist || "N/A", + title: song?.name || "N/A" + })) + } + />, + ({ + imageUrl: song?.image || "", + number: `${index + 6}`, + subtitle: song?.artist || "N/A", + title: song?.name || "N/A" + })) + } + /> + + ] } + /> + + ); +} diff --git a/src/features/overview/components/UsersDataAnalysis.tsx b/src/features/overview/components/UsersDataAnalysis.tsx new file mode 100644 index 0000000..2c09adc --- /dev/null +++ b/src/features/overview/components/UsersDataAnalysis.tsx @@ -0,0 +1,45 @@ +import { Fragment } from "react/jsx-runtime"; +import { SubSectionCard } from "../../../pages/app/components/SubSectionCard"; +import { SubSectionLayout } from "../../../pages/app/components/SubSectionLayout"; +import useUsersData from "../../../states/usersData/hooks/useUsersData"; + +/** + * Component to display the users data analysis section + */ +export default function UsersDataAnalyst() { + const { + isUsersDataError, + usersData + } = useUsersData(); + + // Show error state + if (isUsersDataError ) { + return
Error loading users data
; + } + + return ( + + , + , + + ] } + /> + + ); +} diff --git a/src/features/overview/components/revenueDataAnalysis.tsx b/src/features/overview/components/revenueDataAnalysis.tsx new file mode 100644 index 0000000..79dd6a6 --- /dev/null +++ b/src/features/overview/components/revenueDataAnalysis.tsx @@ -0,0 +1,41 @@ +import { Fragment } from "react/jsx-runtime"; +import { SubSectionCard } from "../../../pages/app/components/SubSectionCard"; +import { SubSectionLayout } from "../../../pages/app/components/SubSectionLayout"; +import useRevenueData from "../../../states/revenueData/hooks/useRevenueData"; + +/** + * Component to display the revenue data analysis section + */ +export default function RevenueDataAnalysis() { + const { + isRevenueDataError, + revenueData + } = useRevenueData(); + + // Show error state + if (isRevenueDataError) { + return
Error loading revenue data
; + } + + return ( + + , + + ] } + /> + + ); +} diff --git a/src/features/overview/layout/OverviewLayout.tsx b/src/features/overview/layout/OverviewLayout.tsx new file mode 100644 index 0000000..9763ee3 --- /dev/null +++ b/src/features/overview/layout/OverviewLayout.tsx @@ -0,0 +1,17 @@ +import Stack from "@mui/material/Stack"; +import RevenueDataAnalysis from "../components/revenueDataAnalysis"; +import TopArtists from "../components/TopArtists"; +import TopSongs from "../components/TopSongs"; +import UsersDataAnalyst from "../components/UsersDataAnalysis"; + +export default function OverviewLayout() { + + return ( + + + + + + + ); +} diff --git a/src/features/overview/pages/OverviewPage.tsx b/src/features/overview/pages/OverviewPage.tsx index 02c2dee..618ee9b 100644 --- a/src/features/overview/pages/OverviewPage.tsx +++ b/src/features/overview/pages/OverviewPage.tsx @@ -1,5 +1,16 @@ +import BillboardDataProvider from "../../../states/billboardData/providers/billboardDataProvider"; +import RevenueDataProvider from "../../../states/revenueData/providers/revenueDataProvider"; +import UsersDataProvider from "../../../states/usersData/providers/usersDataProvider"; +import OverviewLayout from "../layout/OverviewLayout"; + export default function OverviewPage() { return ( -
Under Construction
+ + + + + + + ); } diff --git a/src/layouts/AppContentLayout.tsx b/src/layouts/AppContentLayout.tsx new file mode 100644 index 0000000..41727d2 --- /dev/null +++ b/src/layouts/AppContentLayout.tsx @@ -0,0 +1,25 @@ +import Container from "@mui/material/Container"; +import { FunctionComponent, ReactElement, ReactNode } from "react"; +import styles from "./styles/AppContentLayout.module.css"; + +interface AppContentLayoutProps { + /** + * Children of the layout + */ + children: ReactNode; +} + +export const AppContentLayout: FunctionComponent = ( + props: AppContentLayoutProps): ReactElement => { + const { + children + } = props; + + return ( + + { children } + + ); +}; diff --git a/src/layouts/mainContentLayout.tsx b/src/layouts/mainContentLayout.tsx index 44a8006..dd1f86b 100644 --- a/src/layouts/mainContentLayout.tsx +++ b/src/layouts/mainContentLayout.tsx @@ -1,5 +1,7 @@ import Grid from "@mui/material/Grid2/Grid2"; +import Stack from "@mui/material/Stack"; import { FunctionComponent, ReactElement, ReactNode } from "react"; +import { AppContentLayout } from "./AppContentLayout"; import styles from "./styles/MainLayout.module.css"; import { UiMainContentTitle } from "../components/uiMainContentTitle/uiMainContentTitle"; import { isScreenMobileOrSmall } from "../utils/utility"; @@ -49,7 +51,7 @@ export const MainContentLayout: FunctionComponent = ( { drawerComponent } = ( - - { content } + + + { content } + @@ -70,18 +74,14 @@ export const MainContentLayout: FunctionComponent = ( flexWrap="wrap" className={ `${styles.mainContentLayout} ${styles.mainContentLayoutMobile}` } > - - - - { drawerComponent } - - - - - - + + + { drawerComponent } + + + { content } - + ) diff --git a/src/layouts/styles/AppContentLayout.module.css b/src/layouts/styles/AppContentLayout.module.css new file mode 100644 index 0000000..80f4ae6 --- /dev/null +++ b/src/layouts/styles/AppContentLayout.module.css @@ -0,0 +1,5 @@ +.appContentLayout { + padding: 24px 0px !important; + margin: 0px !important; + max-width: 100% !important; +} \ No newline at end of file diff --git a/src/pages/app/components/SubSectionCard.tsx b/src/pages/app/components/SubSectionCard.tsx new file mode 100644 index 0000000..4212e16 --- /dev/null +++ b/src/pages/app/components/SubSectionCard.tsx @@ -0,0 +1,101 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Grid from "@mui/material/Grid2"; +import Typography from "@mui/material/Typography"; +import { FunctionComponent, ReactElement } from "react"; +import styles from "../styles/SubSectionCard.module.css"; + +interface SubSectionCardProps { + /** + * Title of the content + */ + title: string; + /** + * Main string content + */ + content: string; + /** + * Image URL + */ + imageUrl?: string | undefined; + /** + * caption + */ + caption?: string | undefined; + /** + * is error text + */ + isError?: boolean | undefined; + /** + * is positive text + */ + isPositive?: boolean | undefined; +} + +/** + * Common card layout for sub section cards + */ +export const SubSectionCard: FunctionComponent = ( + props: SubSectionCardProps): ReactElement => { + + const { + title, + content, + imageUrl, + caption, + isError, + isPositive + } = props; + + /** + * Get the text class name based on the error and positive state + */ + const getTextClassName = (): string => + `${isError ? styles.errorText : ""} ${isPositive ? styles.positiveText : ""}`; + + return ( + + + + { + imageUrl + ? ( + + random + + ) : null + } + + + { title } + + + { content } + + { + caption + ? ( + + { caption } + + ) : null + } + + + + + + ); +}; diff --git a/src/pages/app/components/SubSectionLayout.tsx b/src/pages/app/components/SubSectionLayout.tsx new file mode 100644 index 0000000..4d18604 --- /dev/null +++ b/src/pages/app/components/SubSectionLayout.tsx @@ -0,0 +1,114 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { motion } from "framer-motion"; +import { Fragment, FunctionComponent, ReactElement, useEffect, useRef, useState } from "react"; +import { isScreenMobileOrSmall, isScreenTabletOrSmaller } from "../../../utils/utility"; +import styles from "../styles/SubSectionLayout.module.css"; + +/** + * Sub section layout for displaying multiple cards in a horizontal scrollable layout + */ +interface SubSectionLayoutProps { + /** + * Title of the sub section + */ + title?: string; + /** + * Subtitle of the sub section + */ + subtitle?: string; + /** + * List of items to display in the sub section + */ + displayItems: ReactElement[]; +} + +export const SubSectionLayout: FunctionComponent = ( + props: SubSectionLayoutProps): ReactElement => { + + const { + title, + subtitle, + displayItems + } = props; + + const containerRef = useRef(null); + const contentRef = useRef(null); + const [ dragConstraints, setDragConstraints ] = useState<{ left: number; right: number }>({ + left: 0, + right: 0 + }); + + useEffect(() => { + const updateDragConstraints = () => { + if (containerRef.current && contentRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const cardSpacing = 16; // Spacing between cards (px), equivalent to `spacing={2}` in the Stack + const cardWidth = contentRef.current.children[0]?.clientWidth || 0; + + let totalCards = displayItems.length; + + if (isScreenMobileOrSmall()) { + totalCards = displayItems.length + 0.16; + } else if (isScreenTabletOrSmaller()) { + totalCards = displayItems.length - 0.42; + } + + const contentWidth = (totalCards * cardWidth) + cardSpacing; + + // Maximum draggable distance to keep the last card aligned + const maxDrag = Math.max(contentWidth - containerWidth, 0); + + setDragConstraints({ left: -maxDrag, right: 0 }); + } + }; + + updateDragConstraints(); + + // Update constraints on window resize + window.addEventListener("resize", updateDragConstraints); + + return () => { + window.removeEventListener("resize", updateDragConstraints); + }; + }, [ displayItems ]); + + return ( + + + { title && title.length > 0 && ( + + { title } + + ) } + { subtitle && subtitle.length > 0 && ( + + { subtitle } + + ) } + + + + + { displayItems.map((item, index) => ( + { item } + )) } + + + + ); +}; diff --git a/src/pages/app/components/SubSectionListCard.tsx b/src/pages/app/components/SubSectionListCard.tsx new file mode 100644 index 0000000..4d93e80 --- /dev/null +++ b/src/pages/app/components/SubSectionListCard.tsx @@ -0,0 +1,88 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Grid from "@mui/material/Grid2"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { FunctionComponent, ReactElement } from "react"; +import styles from "../styles/SubSectionCard.module.css"; + + +export interface SubSectionListCardItem { + /** + * Number + */ + number: string; + /** + * Title + */ + title: string; + /** + * Subtitle + */ + subtitle?: string; + /** + * image URL + */ + imageUrl: string; +} + +interface SubSectionListCardProps { + /** + * content list + */ + contentList: SubSectionListCardItem[]; +} + +/** + * Common card layout for sub section cards + */ +export const SubSectionListCard: FunctionComponent = ( + props: SubSectionListCardProps): ReactElement => { + + const { + contentList + } = props; + + return ( + + + + { + contentList.map((contentItem: SubSectionListCardItem) => ( + + + + { contentItem.number } + + + random + + + { contentItem.title } + + { + contentItem.subtitle + && ( + + { contentItem.subtitle } + + ) + } + + + )) + } + + + + ); +}; diff --git a/src/pages/app/components/AppLayout.tsx b/src/pages/app/layout/AppLayout.tsx similarity index 100% rename from src/pages/app/components/AppLayout.tsx rename to src/pages/app/layout/AppLayout.tsx diff --git a/src/pages/app/pages/App.tsx b/src/pages/app/pages/App.tsx index 101c999..b46a27d 100644 --- a/src/pages/app/pages/App.tsx +++ b/src/pages/app/pages/App.tsx @@ -1,15 +1,18 @@ import { AuthenticatedComponent } from "@asgardeo/auth-react"; +import BillboardDataProvider from "../../../states/billboardData/providers/billboardDataProvider"; import InternalAuthDataProvider from "../../../states/internalAuthData/providers/internalAuthDataProvider"; import RouteDataProvider from "../../../states/routeData/providers/routeDateProvider"; import { NotFound } from "../../errors/notFound"; -import AppLayout from "../components/AppLayout"; +import AppLayout from "../layout/AppLayout"; function App() { return ( }> - + + + diff --git a/src/pages/app/styles/SubSectionCard.module.css b/src/pages/app/styles/SubSectionCard.module.css new file mode 100644 index 0000000..88f9f9f --- /dev/null +++ b/src/pages/app/styles/SubSectionCard.module.css @@ -0,0 +1,42 @@ +.subSectionCardImage { + height: 100%; + width: 100%; + object-fit: cover; + border-radius: 12px; +} + +.subSectionListCardImage { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 4px; +} + +.subSectionCard { + width: 100%; + min-width: 300px; + display: flex; +} + +.subSectionCardContent { + height: auto; + width: 100%; +} + +.errorText { + color: var(--color-danger) !important; +} + +.positiveText { + color: var(--color-accept) !important; +} + +.subSectionCardListContainer { + height: 100%; +} + +@media screen and (max-width: 468px) { + .subSectionCard { + min-width: 270px; + } +} diff --git a/src/pages/app/styles/SubSectionLayout.module.css b/src/pages/app/styles/SubSectionLayout.module.css new file mode 100644 index 0000000..ebf6148 --- /dev/null +++ b/src/pages/app/styles/SubSectionLayout.module.css @@ -0,0 +1,27 @@ +.subSectionLayout { + overflow: hidden; + position: relative; +} + +.subSectionLayoutTitle { + font-size: 20px; + font-weight: 600; +} + +.subSectionMotionLayoutContainer { + display: flex; + position: relative; + cursor: grab; +} + +.subSectionLayoutContent { + cursor: grab; + display: flex; + width: 100%; +} + +@media screen and (max-width: 468px) { + .subSectionLayoutContent { + width: 96vw; + } +} diff --git a/src/pages/login/components/LoginPageLayout.tsx b/src/pages/login/layout/LoginPageLayout.tsx similarity index 90% rename from src/pages/login/components/LoginPageLayout.tsx rename to src/pages/login/layout/LoginPageLayout.tsx index c39c9bc..7a106fe 100644 --- a/src/pages/login/components/LoginPageLayout.tsx +++ b/src/pages/login/layout/LoginPageLayout.tsx @@ -82,6 +82,13 @@ function LoginPageLayout() { troubleshooting, feel free to contact support at admin@streamify.com. + + + Note: Since this is a demo application, you can use the following + credentials to log in. Use "test@streamify.com" as the username + and "Test@123" as the password. + + diff --git a/src/pages/login/pages/loginPage.tsx b/src/pages/login/pages/loginPage.tsx index 6ee2e5c..5414d74 100644 --- a/src/pages/login/pages/loginPage.tsx +++ b/src/pages/login/pages/loginPage.tsx @@ -1,5 +1,5 @@ import InternalAuthDataProvider from "../../../states/internalAuthData/providers/internalAuthDataProvider"; -import LoginPageLayout from "../components/LoginPageLayout"; +import LoginPageLayout from "../layout/LoginPageLayout"; import "../styles/loginPage.css"; function LoginPage() { diff --git a/src/states/billboardData/api/getTopArtists.ts b/src/states/billboardData/api/getTopArtists.ts index e0a8aef..dd5b800 100644 --- a/src/states/billboardData/api/getTopArtists.ts +++ b/src/states/billboardData/api/getTopArtists.ts @@ -11,7 +11,7 @@ export const getTopArtists = async (): Promise => { return APIClient.getInstance() .get(Endpoints.topArtists) .then((response) => { - return response.data as unknown as ArtistResponse; + return response as unknown as ArtistResponse; }) .catch((error) => { throw error; diff --git a/src/states/billboardData/api/getTopSongs.ts b/src/states/billboardData/api/getTopSongs.ts index 8f2d135..740112c 100644 --- a/src/states/billboardData/api/getTopSongs.ts +++ b/src/states/billboardData/api/getTopSongs.ts @@ -9,9 +9,9 @@ import { SongsResponse } from "../models/Song"; */ export const getTopSongs = async (): Promise => { return APIClient.getInstance() - .get(Endpoints.topArtists) + .get(Endpoints.topSongs) .then((response) => { - return response.data as unknown as SongsResponse; + return response as unknown as SongsResponse; }) .catch((error) => { throw error; diff --git a/src/states/billboardData/providers/billboardDataProvider.tsx b/src/states/billboardData/providers/billboardDataProvider.tsx index a35cfb9..3461fd0 100644 --- a/src/states/billboardData/providers/billboardDataProvider.tsx +++ b/src/states/billboardData/providers/billboardDataProvider.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, PropsWithChildren, ReactElement, useEffect, useState } from "react"; +import { FunctionComponent, PropsWithChildren, ReactElement, useMemo, useState } from "react"; import { getTopArtists } from "../api/getTopArtists"; import { getTopSongs } from "../api/getTopSongs"; import BillboardDataContext from "../contexts/billboardDataContext"; @@ -30,7 +30,7 @@ const BillboardDataProvider: FunctionComponent = ( const [ isTopSongsError, setIsTopSongsError ] = useState(false); const [ songsRankingDate, setSongsRankingDate ] = useState(""); - useEffect(() => { + useMemo(() => { const loadTopArtists = async () => { try { const response = await getTopArtists(); @@ -49,7 +49,7 @@ const BillboardDataProvider: FunctionComponent = ( loadTopArtists(); }, [ getTopArtists ]); - useEffect(() => { + useMemo(() => { const loadTopSongs = async () => { try { const response = await getTopSongs(); diff --git a/src/states/revenueData/contexts/revenueDataContext.ts b/src/states/revenueData/contexts/revenueDataContext.ts new file mode 100644 index 0000000..4d04428 --- /dev/null +++ b/src/states/revenueData/contexts/revenueDataContext.ts @@ -0,0 +1,32 @@ +import { Context, createContext } from "react"; +import { RevenueData } from "../models/revenueData"; + +/** + * Props interface for RevenueDataContext. + */ +export interface RevenueDataContextProps { + /** + * Revenue data. + */ + revenueData: RevenueData | null; + /** + * Flag indicating if an error occurred while loading the revenue data. + */ + isRevenueDataError: boolean; +} + +/** + * Context object for managing the RevenueDataContext. + */ +const RevenueDataContext: Context = createContext< + null | RevenueDataContextProps +>( + null +); + +/** + * Display name for the RevenueDataContext. + */ +RevenueDataContext.displayName = "RevenueDataContext"; + +export default RevenueDataContext; diff --git a/src/states/revenueData/hooks/useRevenueData.ts b/src/states/revenueData/hooks/useRevenueData.ts new file mode 100644 index 0000000..94e45ab --- /dev/null +++ b/src/states/revenueData/hooks/useRevenueData.ts @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import RevenueDataContext, { RevenueDataContextProps } from "../contexts/revenueDataContext"; + +/** + * Interface for the return type of the `useRevenueData` hook. + */ +export type UseRevenueDataInterface = RevenueDataContextProps; + +/** + * Hook that provides access to the information about the Revenue data. + * @returns An object containing the Revenue related data. + */ +const useRevenueData = (): UseRevenueDataInterface => { + const context: RevenueDataContextProps | null = useContext(RevenueDataContext); + + if (!context) { + throw new Error("useRevenueData must be used within a RevenueDataProvider"); + } + + return context; +}; + +export default useRevenueData; diff --git a/src/states/revenueData/models/revenueData.ts b/src/states/revenueData/models/revenueData.ts new file mode 100644 index 0000000..2ba6423 --- /dev/null +++ b/src/states/revenueData/models/revenueData.ts @@ -0,0 +1,13 @@ +/** + * Represents the revenue data. + */ +export interface RevenueData { + /** + * The total revenue. + */ + totalRevenue: number; + /** + * The total revenue change compared to last month. + */ + comparedToLastMonth: string; +} diff --git a/src/states/revenueData/providers/revenueDataProvider.tsx b/src/states/revenueData/providers/revenueDataProvider.tsx new file mode 100644 index 0000000..0210a6e --- /dev/null +++ b/src/states/revenueData/providers/revenueDataProvider.tsx @@ -0,0 +1,49 @@ +import { FunctionComponent, PropsWithChildren, ReactElement, useMemo, useState } from "react"; +import revenueDataJson from "../../../assets/data/revenueData.json"; +import RevenueDataContext from "../contexts/revenueDataContext"; +import { RevenueData } from "../models/revenueData"; + +/** + * Props interface for the [RevenueDataProvider] + */ +export type RevenueDataProviderProps = PropsWithChildren; + +/** + * React context provider for the revenue data. + * + * @param props - Props injected to the component. + * @returns Internal authentication data context instance. + */ +const RevenueDataProvider: FunctionComponent = ( + props: RevenueDataProviderProps +): ReactElement => { + const { children } = props; + + const [ isRevenueDataError, setIsRevenueDataError ] = useState(false); + const [ RevenueData, setRevenueData ] = useState(null); + + useMemo(() => { + try { + if (revenueDataJson) { + setRevenueData(revenueDataJson); + setIsRevenueDataError(false); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + setIsRevenueDataError(true); + } + }, [ revenueDataJson ]); + + return ( + + { children } + + ); +}; + +export default RevenueDataProvider; diff --git a/src/states/usersData/contexts/usersDataContext.ts b/src/states/usersData/contexts/usersDataContext.ts new file mode 100644 index 0000000..49937c6 --- /dev/null +++ b/src/states/usersData/contexts/usersDataContext.ts @@ -0,0 +1,32 @@ +import { Context, createContext } from "react"; +import { UsersData } from "../models/UsersData"; + +/** + * Props interface for UsersDataContext. + */ +export interface UsersDataContextProps { + /** + * Users data. + */ + usersData: UsersData | null; + /** + * Flag indicating if an error occurred while loading the users data. + */ + isUsersDataError: boolean; +} + +/** + * Context object for managing the UsersDataContext. + */ +const UsersDataContext: Context = createContext< + null | UsersDataContextProps +>( + null +); + +/** + * Display name for the UsersDataContext. + */ +UsersDataContext.displayName = "UsersDataContext"; + +export default UsersDataContext; diff --git a/src/states/usersData/hooks/useUsersData.ts b/src/states/usersData/hooks/useUsersData.ts new file mode 100644 index 0000000..374b39d --- /dev/null +++ b/src/states/usersData/hooks/useUsersData.ts @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import UsersDataContext, { UsersDataContextProps } from "../contexts/usersDataContext"; + +/** + * Interface for the return type of the `useUsersData` hook. + */ +export type UseUsersDataInterface = UsersDataContextProps; + +/** + * Hook that provides access to the information about the users data. + * @returns An object containing the Users related data. + */ +const useUsersData = (): UseUsersDataInterface => { + const context: UsersDataContextProps | null = useContext(UsersDataContext); + + if (!context) { + throw new Error("useUsersData must be used within a UsersDataProvider"); + } + + return context; +}; + +export default useUsersData; diff --git a/src/states/usersData/models/UsersData.ts b/src/states/usersData/models/UsersData.ts new file mode 100644 index 0000000..2547f83 --- /dev/null +++ b/src/states/usersData/models/UsersData.ts @@ -0,0 +1,19 @@ +/** + * Represents the data of users. + */ +export interface UsersData { + /** + * Total number of users. + */ + totalUsers: number; + + /** + * Total number of active users. + */ + activeUsers: number; + + /** + * Total number of inactive users. + */ + unsubscribedUsers: number; +} diff --git a/src/states/usersData/providers/usersDataProvider.tsx b/src/states/usersData/providers/usersDataProvider.tsx new file mode 100644 index 0000000..c71e7e1 --- /dev/null +++ b/src/states/usersData/providers/usersDataProvider.tsx @@ -0,0 +1,49 @@ +import { FunctionComponent, PropsWithChildren, ReactElement, useMemo, useState } from "react"; +import usersDataJson from "../../../assets/data/userData.json"; +import UsersDataContext from "../contexts/usersDataContext"; +import { UsersData } from "../models/UsersData"; + +/** + * Props interface for the [UsersDataProvider] + */ +export type UsersDataProviderProps = PropsWithChildren; + +/** + * React context provider for the users data. + * + * @param props - Props injected to the component. + * @returns Internal authentication data context instance. + */ +const UsersDataProvider: FunctionComponent = ( + props: UsersDataProviderProps +): ReactElement => { + const { children } = props; + + const [ isUsersDataError, setIsUsersDataError ] = useState(false); + const [ usersData, setUsersData ] = useState(null); + + useMemo(() => { + try { + if (usersDataJson) { + setUsersData(usersDataJson); + setIsUsersDataError(false); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + setIsUsersDataError(true); + } + }, [ usersDataJson ]); + + return ( + + { children } + + ); +}; + +export default UsersDataProvider; diff --git a/src/styles/index.css b/src/styles/index.css index c788083..8eba88f 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -17,6 +17,7 @@ --color-text-secondary: #6F7A88 ; --color-card-stroke: #C2D1FF64; --color-danger: #9C4B50; + --color-accept: #549C4B; --drawer-hover-card: linear-gradient(0.25turn, var(--color-white-primary), #FDFDFF00); --drawer-hover-card-background-blur: 100px; --drawer-hover-card-shadow: 0 4px 8px 0 #4b6c9b15; diff --git a/src/theme/appTheme.ts b/src/theme/appTheme.ts index 691e797..0639aca 100644 --- a/src/theme/appTheme.ts +++ b/src/theme/appTheme.ts @@ -88,6 +88,28 @@ export const appTheme = responsiveFontSizes(createTheme({ } ] }, + MuiCard: { + defaultProps: { + variant: "outlined" + }, + styleOverrides: { + root: { + borderRadius: "16px", + borderColor: Colors.colorWhiteTernary, + backdropFilter: "blur(24px)", + boxShadow: "0 4px 8px 0 rgba(75, 109, 155, 0.04)", + background: `linear-gradient(to right, + ${ Colors.colorWhitePrimary }, ${ Colors.colorWhiteSecondary })` + } + } + }, + MuiCardContent: { + styleOverrides: { + root: { + padding: "16px !important" + } + } + }, MuiDrawer: { defaultProps: { variant: "permanent" @@ -189,6 +211,10 @@ export const appTheme = responsiveFontSizes(createTheme({ fontSize: "12px", color: Colors.colorTextSecondary }, + h3: { + fontFamily: Font.defaultFontFamily, + color: Colors.colorTextPrimary + }, h4: { fontFamily: Font.secondaryFontFamily }, @@ -197,6 +223,11 @@ export const appTheme = responsiveFontSizes(createTheme({ color: Colors.colorPrimary, fontWeight: "bold" }, + h6: { + fontFamily: Font.defaultFontFamily, + color: Colors.colorPrimary, + fontWeight: "semibold" + }, caption: { fontFamily: Font.defaultFontFamily, fontSize: "12px",