diff --git a/src/components/index.ts b/src/components/index.ts index 07d9ca9..6365340 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ export * from './buttons'; export * from './channel-icon-menu'; export * from './error-renderer'; +export * from './ui/header' export * from './layout'; export * from './loaders'; export * from './modal'; diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 682491e..649ce8f 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -22,6 +22,7 @@ export const AppContainer = styled.default.div` export const Body = styled.default.div` flex: 1; padding: 1rem ${Theme.spacing.xlarge}; + padding-top: 5.5rem; /* Account for fixed header height (3.5rem) + padding */ background: ${Theme.colors.gray900}; overflow: hidden; display: flex; diff --git a/src/components/menu/index.tsx b/src/components/menu/index.tsx new file mode 100644 index 0000000..672125d --- /dev/null +++ b/src/components/menu/index.tsx @@ -0,0 +1,51 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {useLocation} from 'react-router-dom'; +import {useSelector} from 'react-redux'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; + +import {RootState} from 'store'; +import urlRoute from 'routers/url-routes'; + +import {MenuLayout, MenuItem} from './style'; + +interface IMenu { + toggleShowMenu: () => void; +} + +const menuTitles = Object.keys(urlRoute).filter((menuTitle: string) => menuTitle !== 'login'); +const Menu = (props: IMenu) => { + const {toggleShowMenu} = props; + const applicationId = useSelector((state: RootState) => state.authentication.applicationId); + const {pathname} = useLocation(); + const currentLocation = pathname.split('/')[2]; + + return ( + + {menuTitles.map((menuItem: string): JSX.Element | null => { + const {path, icon, notSupported} = urlRoute[menuItem]; + + if (notSupported) { + return null; + } + + const active = currentLocation === path; + + return ( + + +

{menuItem}

+
+ ); + })} +
+ ); +}; + +export default Menu; \ No newline at end of file diff --git a/src/components/menu/style.ts b/src/components/menu/style.ts new file mode 100644 index 0000000..21429b8 --- /dev/null +++ b/src/components/menu/style.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {Link} from 'components/ui'; +import {theme, paddings} from 'components/shared/theme'; + +const { + colors, + primaryThemeColor +} = theme; + +export const MenuLayout = styled.default.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +`; + +export const MenuItem = styled.default(Link)` + display: flex; + align-items: center; + width: 100%; + height: 44px; + padding: 0 ${paddings.xlarge}; + color: ${colors.white}; + text-transform: capitalize; + text-decoration: none; + cursor: pointer; + &.active { + color: ${primaryThemeColor}; + } + &:hover { + background: ${colors.headerColor}; + } + & span { + font-size: 22px; + margin: 0 ${paddings.medium} 0 0; + } +`; \ No newline at end of file diff --git a/src/components/shared/utils.ts b/src/components/shared/utils.ts index 9205bdd..7221462 100644 --- a/src/components/shared/utils.ts +++ b/src/components/shared/utils.ts @@ -2,7 +2,7 @@ * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. */ import moment, {DurationInputArg1, unitOfTime} from 'moment'; -import PlatformDetectionService from 'services/platformDetection.service'; +import PlatformDetectionService from 'services/PlatformDetection.service'; export const truncateWord = (word = '', length = 30): string => { if (word.length >= length) { diff --git a/src/components/side-menu/index.tsx b/src/components/side-menu/index.tsx new file mode 100644 index 0000000..903aca2 --- /dev/null +++ b/src/components/side-menu/index.tsx @@ -0,0 +1,56 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {JSX} from 'react'; +import {useAppDispatch, useAppSelector, RootState} from 'store'; +import {setPreferredTimeFormat} from 'store/action/preferred-time-format'; +import {TimeFormats} from 'utility'; + +import {documentationLinks} from 'constants/links'; + +import Menu from 'components/menu'; +import NewTabLink from 'components/new-tab-link'; +import Toggle from 'components/toggle'; + +import { + Overlay, + MenuView, + SnapToBottom, + FooterContainer +} from './style'; +import config from 'config'; + +export const SideMenu = ({showMenu}: {showMenu: () => void }): JSX.Element => { + const dispatch = useAppDispatch(); + const timeFormat = useAppSelector((state: RootState) => state.preferredTimeFormat.timeFormat); + const handleToggleChange = () => { + if (timeFormat === TimeFormats.Utc) { + dispatch(setPreferredTimeFormat(TimeFormats.LocalTime)); + } else { + dispatch(setPreferredTimeFormat(TimeFormats.Utc)); + } + }; + + return ( + <> + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/side-menu/style.ts b/src/components/side-menu/style.ts new file mode 100644 index 0000000..a73cc8b --- /dev/null +++ b/src/components/side-menu/style.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {theme, paddings} from 'components/shared/theme'; +import {ToggleContainer} from 'components/toggle/style'; + +const {colors, footerHeight, typography: {fontSizeS}} = theme; + +export const Overlay = styled.default.div` + position: fixed; + width: 100%; + min-height: calc(100% - 3.5rem - ${footerHeight}); + top: 3.5rem; + right: 0; + background: ${colors.headerColor}; + opacity: .7; + z-index: 9; +`; + +export const MenuView = styled.default.div` + display: block; + position: fixed; + left: 0; + top: 3.5rem; + height: calc(100% - 3.5rem - ${footerHeight}); + background-color: ${colors.black}; + width: 292px; + padding-top: ${paddings.small}; + z-index: 11; + box-shadow: 2px 0px 1px rgba(1,1,1, .9); +`; + +export const SnapToBottom = styled.default.div` + width: inherit; + position: absolute; + bottom: 0; +`; + +export const FooterContainer = styled.default.div` + width: 100%; + position: relative; + color: ${colors.gray300}; + font-size: ${fontSizeS}; + padding: 0 ${paddings.medium}; + + a { + text-decoration: none; + color: ${colors.lightBlue}; + padding: 1rem 0; + display: block; + } + + ${ToggleContainer} { + justify-content: flex-start; + } +`; \ No newline at end of file diff --git a/src/components/table-screen-header/style.tsx b/src/components/table-screen-header/style.tsx index ed441a2..c0d1fb5 100644 --- a/src/components/table-screen-header/style.tsx +++ b/src/components/table-screen-header/style.tsx @@ -12,7 +12,8 @@ export const ScreenHeader = styled.default.div` align-items: baseline; flex-wrap: wrap; width: 100%; - margin: 0 0 .5rem; + margin: 0 0 0.75rem; + padding: 0 ${Theme.spacing.medium}; `; export const ScreenHeaderControls = styled.default.div` @@ -29,9 +30,12 @@ export const HeaderTitle = styled.default.div` align-items: baseline; h2 { color: ${colors.white}; - margin: 0 .5rem 0 0; + margin: 0; + font-size: 1.25rem; + font-weight: 600; } p { color: ${colors.gray200}; + margin: 0; } `; diff --git a/src/components/table/style.tsx b/src/components/table/style.tsx index 571ee3c..dd90c4d 100644 --- a/src/components/table/style.tsx +++ b/src/components/table/style.tsx @@ -23,10 +23,17 @@ export const Thead = styled.default.thead` & th { top: -1px; background-color: ${primaryBackground}; - padding: ${Theme.spacing.small} 0; + padding: ${Theme.spacing.small} ${Theme.spacing.small}; z-index: 10; + text-align: left; + font-weight: 600; + + &:first-child { + padding-left: ${Theme.spacing.medium}; + } &:last-child { + padding-right: ${Theme.spacing.medium}; border: none; } } @@ -49,21 +56,30 @@ export const Tbody = styled.default.tbody` } & tr { border-color: transparent transparent ${colors.black} transparent; - height: 3rem; + height: 2.5rem; & td { color: ${colors.white}; - padding: ${Theme.spacing.small}; + padding: ${Theme.spacing.xxSmall}; word-wrap: break-word; font-size: ${typography.fontSizeS}; position: relative; - text-align: center; + text-align: left; white-space: nowrap; - overflow: visible; + overflow: hidden; + text-overflow: ellipsis; + + &:first-child { + padding-left: ${Theme.spacing.medium}; + } + + &:last-child { + padding-right: ${Theme.spacing.medium}; + } & > div, & > p { - text-align: center; - justify-content: center; + text-align: left; + justify-content: flex-start; } & a { diff --git a/src/components/toggle/index.tsx b/src/components/toggle/index.tsx new file mode 100644 index 0000000..91a593a --- /dev/null +++ b/src/components/toggle/index.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import { + ToggleContainer, + ToggleHiddenInput, + ToggleSwitchContainer, + ToggleSwitch, + ToggleTitleOn, + ToggleTitleOff +} from './style'; + +interface IToggle { + handleChange: () => void; + onTitle?: string; + offTitle?: string; + position?: 'on' | 'off'; +} +const Toggle = (props: IToggle): JSX.Element => { + const { + onTitle, + offTitle, + handleChange, + position + } = props; + + return ( + + + {offTitle && {offTitle}} + + + + {onTitle && {onTitle}} + + ); +}; + +export default Toggle; \ No newline at end of file diff --git a/src/components/toggle/style.ts b/src/components/toggle/style.ts new file mode 100644 index 0000000..4c2cb18 --- /dev/null +++ b/src/components/toggle/style.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; + +import {theme} from 'components/shared/theme'; + +const {colors, typography: {fontSizeS}} = theme; + +export const ToggleContainer = styled.default.div` + display: flex; + justify-content: center; + align-items: center; +`; + +const ToggleTitle = styled.default.p` + color: ${colors.white}; + font-size: ${fontSizeS}; + transition: all .2s ease-in-out; +`; + +export const ToggleTitleOn = styled.default(ToggleTitle)` + opacity: 1; +`; +export const ToggleTitleOff = styled.default(ToggleTitle)` + opacity: 0.5; +`; + +export const ToggleSwitchContainer = styled.default.label` + margin: 0 1rem; + width: 3rem; + height: 1.5rem; + background-color: transparent; + border: 2px solid ${colors.gray700}; + border-radius: 40px; + display: flex; + justify-content: center; + align-items: center; + position: relative; + cursor: pointer; +`; + +export const ToggleSwitch = styled.default.div` + position: absolute; + width: 1rem; + height: 1rem; + background-color: ${colors.lightBlue}; + border-radius: 40px; + left: .2rem; + transition: left 200ms linear; +`; + +export const ToggleHiddenInput = styled.default.input` + display: none; + + &:checked { + ~${ToggleTitleOff} { + opacity: 0.5; + } + ~${ToggleTitleOn} { + opacity: 1; + } + ~${ToggleSwitchContainer} { + ${ToggleSwitch} { + left: 1.6rem; + } + } + } +`; \ No newline at end of file diff --git a/src/components/ui/header/index.tsx b/src/components/ui/header/index.tsx new file mode 100644 index 0000000..e8093b5 --- /dev/null +++ b/src/components/ui/header/index.tsx @@ -0,0 +1,140 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import { + useState, + useEffect, + useRef, + JSX +} from 'react'; +import {useNavigate, useLocation} from 'react-router-dom'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faSignOutAlt} from '@fortawesome/free-solid-svg-icons'; + +import UserStoreService from 'services/user-store'; +import {RootState, useAppDispatch, useAppSelector} from 'store'; +import {selectIsAuthenticated, signoutThunk} from 'store/slices/Authentication.slice'; +import text from './text'; + +import {SideMenu} from 'components/side-menu'; +import urlRoutes from 'routers/url-routes'; + +import { + TopNavigation, + User, + ApplicationId, + UserInitials, + MenuIcon, + NavigationLeftSide +} from './style'; + +import logo from 'assets/images/phenix-logo-101x41.png'; +import menuIcon from 'assets/images/icon/menu.svg'; + +const Header = (): JSX.Element => { + const ref = useRef(null); + const dispatch = useAppDispatch(); + const {search, pathname}: {search: string; pathname: string} = useLocation(); + const applicationId = useAppSelector((state: RootState) => state.authentication.applicationId); + const [viewUserDetails, setViewUserDetails] = useState(false); + const [showMenu, setShowMenu] = useState(false); + const isLoggedIn = useAppSelector(selectIsAuthenticated); + const navigate = useNavigate(); + const {phenixText} = text; + let userInitials = ''; + + if (applicationId) { + userInitials = applicationId.substring(0, 2); + } + + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + setViewUserDetails(false); + } + }; + + const checkAndRedirect = async(): Promise => { + const applicationId = await UserStoreService.get('applicationId') as string; + + if (!applicationId) { + return navigate(`/login/${search}`); + } + + const lastVisitedRoutes = await UserStoreService.get>('lastVisitedRouteByApplicationId') as Record; + const lastVisitedRoute = lastVisitedRoutes[applicationId]; + const channelsPath = `/${urlRoutes.channels.path}/`; + + if (!pathname.includes(applicationId)) { + if (lastVisitedRoute) { + return navigate(`${lastVisitedRoute}${search}`); + } + + return navigate(`${applicationId}${channelsPath}${search}`); + } + }; + + // const setLastVisitedRoute = async(): Promise => { + // const applicationId = await userStore.get('applicationId'); + // const lastVisitedRoutes = await userStore.get('lastVisitedRouteByApplicationId') || {}; + + // await userStore.set('lastVisitedRouteByApplicationId', { + // ...lastVisitedRoutes, + // [applicationId]: pathname + // }); + // }; + + useEffect(() => { + checkAndRedirect(); + }, [isLoggedIn]); + + // useEffect(() => { + // setLastVisitedRoute(); + // }, [pathname]); + + useEffect(() => { + document.addEventListener('click', handleClickOutside, true); + + return () => { + document.removeEventListener('click', handleClickOutside, true); + }; + }); + + const handleLogout = async(): Promise => { + dispatch(signoutThunk()); + }; + + const handleCaretClick = () => setViewUserDetails(!viewUserDetails); + + console.log('isLoggedIn', isLoggedIn); + + return ( + + + {(isLoggedIn && applicationId) && + setShowMenu(!showMenu)} + src={menuIcon} + alt="menuIcon" + /> + } + {phenixText} + + {(isLoggedIn && applicationId) && + <> + + {userInitials} + {applicationId} +
+ +
+
+ {showMenu && + setShowMenu(!showMenu)} /> + } + + } +
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/src/components/ui/header/style.tsx b/src/components/ui/header/style.tsx new file mode 100644 index 0000000..c525a04 --- /dev/null +++ b/src/components/ui/header/style.tsx @@ -0,0 +1,76 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import * as styled from 'styled-components'; +import {theme, paddings} from 'components/shared/theme'; + +const {colors, mediaPhone} = theme; + +export const TopNavigation = styled.default.div<{isLoggedIn: boolean}>` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + padding: ${paddings.medium}; + height: 3.5rem; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + ${({isLoggedIn}) => isLoggedIn && styled.css` + background-color: ${colors.headerColor}; + padding: ${paddings.small} ${paddings.xlarge}; + `} +`; + +export const NavigationLeftSide = styled.default.div` + display: flex; + align-items: center; +`; + +export const MenuIcon = styled.default.img` + margin-right: ${paddings.medium}; + width: 1.5rem; +`; + +export const User = styled.default.div` + display: flex; + align-items: center; + margin-left: auto; + color: ${colors.white}; + gap: ${paddings.small}; + + & div[role="button"] { + cursor: pointer; + padding: ${paddings.xsmall}; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } +`; + +export const ApplicationId = styled.default.p` + margin: 0; + padding: 0 ${paddings.small}; +`; + +export const UserInitials = styled.default.div` + color: ${colors.white}; + background-color: ${colors.lightBlue}; + display: flex; + font-weight: bold; + justify-content: center; + align-items: center; + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + text-transform: uppercase; + ${mediaPhone} { + display: none; + } +`; \ No newline at end of file diff --git a/src/components/ui/header/text.ts b/src/components/ui/header/text.ts new file mode 100644 index 0000000..18ce8dc --- /dev/null +++ b/src/components/ui/header/text.ts @@ -0,0 +1,7 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +export default { + applicationIdText: 'Application Id', + phenixText: 'Phenix' +}; \ No newline at end of file diff --git a/src/routers/index.tsx b/src/routers/index.tsx index 338cc62..b338b46 100644 --- a/src/routers/index.tsx +++ b/src/routers/index.tsx @@ -1,18 +1,24 @@ import {BrowserRouter, Route, Routes, Navigate} from 'react-router-dom'; import {ProtectedRoute} from 'components'; -import {LoginForm, ChannelList, ChannelDetail} from 'views'; +import {LoginForm} from 'views'; +import Header from 'components/ui/header'; +import {lazy, Suspense} from 'react'; + +const ChannelList = lazy(() => import('views/ChannelList').then(module => ({default: module.ChannelList}))); +const ChannelDetail = lazy(() => import('views/ChannelDetail').then(module => ({default: module.ChannelDetail}))); export default function Router() { return ( +
{/* Public routes */} - } /> + } /> {/* Protected routes */} } /> - } />} /> - } />} /> + } />} /> + } />} /> {/* Fallback route */} } /> diff --git a/src/routers/url-routes.ts b/src/routers/url-routes.ts new file mode 100644 index 0000000..76e6660 --- /dev/null +++ b/src/routers/url-routes.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {faStream, faChartBar, faLayerGroup, faCheckSquare, faTh} from '@fortawesome/free-solid-svg-icons'; + +export default { + login: {path: '/login'}, + channels: { + path: 'channels', + icon: faStream + }, + rooms: { + path: 'rooms', + icon: faLayerGroup + }, + analytics: { + path: 'analytics', + icon: faChartBar + }, + qos: { + path: 'qos', + icon: faCheckSquare, + notSupported: true + }, + dashboard: { + path: 'dashboard', + icon: faTh + } +}; \ No newline at end of file diff --git a/src/services/Authentication.service.ts b/src/services/Authentication.service.ts index 79bcfcd..3fcc87b 100644 --- a/src/services/Authentication.service.ts +++ b/src/services/Authentication.service.ts @@ -31,6 +31,10 @@ class AuthenticationService { return this._token; } + public hasLoginToken(): boolean { + return this._token !== null; + } + async authenticate(applicationId: string, secret: string): Promise { const authenticate = { // @ts-expect-error TODO(AZ): phenix-web-proto does not have Typescript types defined definition @@ -39,8 +43,8 @@ class AuthenticationService { deviceId: '', platform: PlatformDetectionService.platform, platformVersion: PlatformDetectionService.platformVersion, - browser: PlatformDetectionService.browser, - browserVersion: PlatformDetectionService.version, + browser: PlatformDetectionService.browserName, + browserVersion: PlatformDetectionService.browserVersion, applicationId, authenticationToken: secret, sessionId: this.sessionId diff --git a/src/services/files/countries_mapping.json b/src/services/files/countries_mapping.json new file mode 100644 index 0000000..3b9c723 --- /dev/null +++ b/src/services/files/countries_mapping.json @@ -0,0 +1,177 @@ +{"AF": "Afghanistan", + "AO": "Angola", + "AL": "Albania", + "AE": "United Arab Emirates", + "AR": "Argentina", + "AM": "Armenia", + "AQ": "Antarctica", + "TF": "French Southern and Antarctic Lands", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BI": "Burundi", + "BE": "Belgium", + "BJ": "Benin", + "BF": "Burkina Faso", + "BD": "Bangladesh", + "BG": "Bulgaria", + "BS": "The Bahamas", + "BA": "Bosnia and Herzegovina", + "BY": "Belarus", + "BZ": "Belize", + "BO": "Bolivia", + "BR": "Brazil", + "BN": "Brunei", + "BT": "Bhutan", + "BW": "Botswana", + "CF": "Central African Republic", + "CA": "Canada", + "CH": "Switzerland", + "CL": "Chile", + "CN": "China", + "CI": "Ivory Coast", + "CM": "Cameroon", + "CD": "Democratic Republic of the Congo", + "CG": "Republic of the Congo", + "CO": "Colombia", + "CR": "Costa Rica", + "CU": "Cuba", + "-99": "Northern Cyprus", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DE": "Germany", + "DJ": "Djibouti", + "DK": "Denmark", + "DO": "Dominican Republic", + "DZ": "Algeria", + "EC": "Ecuador", + "EG": "Egypt", + "ER": "Eritrea", + "ES": "Spain", + "EE": "Estonia", + "ET": "Ethiopia", + "FI": "Finland", + "FJ": "Fiji", + "FK": "Falkland Islands", + "FR": "France", + "GA": "Gabon", + "GB": "England", + "GE": "Georgia", + "GH": "Ghana", + "GN": "Guinea", + "GM": "Gambia", + "GW": "Guinea Bissau", + "GQ": "Equatorial Guinea", + "GR": "Greece", + "GL": "Greenland", + "GT": "Guatemala", + "GY": "Guyana", + "HN": "Honduras", + "HR": "Croatia", + "HT": "Haiti", + "HU": "Hungary", + "ID": "Indonesia", + "IN": "India", + "IE": "Ireland", + "IR": "Iran", + "IQ": "Iraq", + "IS": "Iceland", + "IL": "Israel", + "IT": "Italy", + "JM": "Jamaica", + "JO": "Jordan", + "JP": "Japan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KG": "Kyrgyzstan", + "KH": "Cambodia", + "KR": "South Korea", + "OSA": "Kosovo", + "KW": "Kuwait", + "LA": "Laos", + "LB": "Lebanon", + "LR": "Liberia", + "LY": "Libya", + "LK": "Sri Lanka", + "LS": "Lesotho", + "LT": "Lithuania", + "LU": "Luxembourg", + "LV": "Latvia", + "MA": "Morocco", + "MD": "Moldova", + "MG": "Madagascar", + "MX": "Mexico", + "MK": "Macedonia", + "ML": "Mali", + "MM": "Myanmar", + "MNE": "Montenegro", + "MN": "Mongolia", + "MZ": "Mozambique", + "MR": "Mauritania", + "MW": "Malawi", + "MY": "Malaysia", + "NA": "Namibia", + "NC": "New Caledonia", + "NE": "Niger", + "NG": "Nigeria", + "NI": "Nicaragua", + "NL": "Netherlands", + "NO": "Norway", + "NP": "Nepal", + "NZ": "New Zealand", + "OM": "Oman", + "PK": "Pakistan", + "PA": "Panama", + "PE": "Peru", + "PH": "Philippines", + "PG": "Papua New Guinea", + "PL": "Poland", + "PR": "Puerto Rico", + "KP": "North Korea", + "PT": "Portugal", + "PY": "Paraguay", + "QA": "Qatar", + "RO": "Romania", + "RU": "Russia", + "RW": "Rwanda", + "EH": "Western Sahara", + "SA": "Saudi Arabia", + "SD": "Sudan", + "SDS": "South Sudan", + "SN": "Senegal", + "SB": "Solomon Islands", + "SL": "Sierra Leone", + "SV": "El Salvador", + "ABV": "Somaliland", + "SO": "Somalia", + "SRB": "Republic of Serbia", + "SR": "Suriname", + "SK": "Slovakia", + "SI": "Slovenia", + "SE": "Sweden", + "SZ": "Swaziland", + "SY": "Syria", + "TD": "Chad", + "TG": "Togo", + "TH": "Thailand", + "TJ": "Tajikistan", + "TM": "Turkmenistan", + "TL": "East Timor", + "TT": "Trinidad and Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TW": "Taiwan", + "TZ": "United Republic of Tanzania", + "UG": "Uganda", + "UA": "Ukraine", + "UY": "Uruguay", + "US": "USA", + "UZ": "Uzbekistan", + "VE": "Venezuela", + "VN": "Vietnam", + "VU": "Vanuatu", + "PS": "West Bank", + "YE": "Yemen", + "ZA": "South Africa", + "ZM": "Zambia", + "ZW": "Zimbabwe"} \ No newline at end of file diff --git a/src/services/user-store/IUserDataStore.ts b/src/services/user-store/IUserDataStore.ts index 0ce7018..a0fb2de 100644 --- a/src/services/user-store/IUserDataStore.ts +++ b/src/services/user-store/IUserDataStore.ts @@ -1,4 +1,4 @@ export default interface IUserDataStore { - get(key: string, defaultValue: string): Promise; + get(key: string, defaultValue?: T): Promise; set(key: string, value: string): Promise; } diff --git a/src/store/action/preferred-time-format.ts b/src/store/action/preferred-time-format.ts new file mode 100644 index 0000000..9a4fe35 --- /dev/null +++ b/src/store/action/preferred-time-format.ts @@ -0,0 +1,132 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {Dispatch} from 'redux'; +import userStore from 'services/user-store'; +import {TimeFormats} from 'utility'; +import {transformToPortalError} from 'utility/error-handler'; + +/** + * ACTION TYPES + */ + +export const GET_PREFERRED_TIME_FORMAT = 'preferred-time-format::GET_PREFERRED_TIME_FORMAT' as const; +export const GET_PREFERRED_TIME_FORMAT_DONE = 'preferred-time-format::GET_PREFERRED_TIME_FORMAT_DONE' as const; +export const GET_PREFERRED_TIME_FORMAT_ERROR = 'preferred-time-format::GET_PREFERRED_TIME_FORMAT_ERROR' as const; +export const SET_PREFERRED_TIME_FORMAT = 'preferred-time-format::SET_PREFERRED_TIME_FORMAT' as const; +export const SET_PREFERRED_TIME_FORMAT_DONE = 'preferred-time-format::SET_PREFERRED_TIME_FORMAT_DONE' as const; +export const SET_PREFERRED_TIME_FORMAT_ERROR = 'preferred-time-format::SET_PREFERRED_TIME_FORMAT_ERROR' as const; + +/** + * INTERFACES + */ + +interface IRequestGetPreferredTimeFormat { + type: typeof GET_PREFERRED_TIME_FORMAT; +} +interface IReceiveGetPreferredTimeFormat { + type: typeof GET_PREFERRED_TIME_FORMAT_DONE; + payload: {data: TimeFormats}; +} +interface IFailedGetPreferredTimeFormat { + type: typeof GET_PREFERRED_TIME_FORMAT_ERROR; + error: null | string; +} +interface IRequestSetPreferredTimeFormat { + type: typeof SET_PREFERRED_TIME_FORMAT; +} +interface IReceiveSetPreferredTimeFormat { + type: typeof SET_PREFERRED_TIME_FORMAT_DONE; + payload: {data: TimeFormats}; +} +interface IFailedSetPreferredTimeFormat { + type: typeof SET_PREFERRED_TIME_FORMAT_ERROR; + error: null | string; +} + +export type GetPreferredTimeFormatActionType = IRequestGetPreferredTimeFormat | IReceiveGetPreferredTimeFormat | IFailedGetPreferredTimeFormat; +export type SetPreferredTimeFormatActionType = IRequestSetPreferredTimeFormat | IReceiveSetPreferredTimeFormat | IFailedSetPreferredTimeFormat; + +interface IGetPreferredTimeFormatActions { + request: () => GetPreferredTimeFormatActionType; + receive: (payload) => GetPreferredTimeFormatActionType; + failed: (error: null | string) => GetPreferredTimeFormatActionType; +} +interface ISetPreferredTimeFormatActions { + request: () => SetPreferredTimeFormatActionType; + receive: (payload) => SetPreferredTimeFormatActionType; + failed: (error: null | string) => SetPreferredTimeFormatActionType; +} + +/** + * ACTIONS + */ + +const getPreferredTimeFormatActions: IGetPreferredTimeFormatActions = { + request: () => ({type: GET_PREFERRED_TIME_FORMAT}), + receive: payload => ({ + type: GET_PREFERRED_TIME_FORMAT_DONE, + payload + }), + failed: error => ({ + type: GET_PREFERRED_TIME_FORMAT_ERROR, + error + }) +}; + +export const getPreferredTimeFormat = () => async(dispatch: Dispatch): Promise => { + const { + request, + receive, + failed + } = getPreferredTimeFormatActions; + + dispatch(request()); + + try { + let preferredTimeFormat = await userStore.get('timeFormat'); + + if (!preferredTimeFormat) { + await userStore.set('timeFormat', TimeFormats.Utc); + preferredTimeFormat = await userStore.get('timeFormat'); + } + + dispatch(receive({data: preferredTimeFormat})); + } catch (e) { + const {message} = transformToPortalError(e); + + dispatch(failed(message || 'An error occurred while getting the preferred time format')); + } +}; + +const setPreferredTimeFormatActions: ISetPreferredTimeFormatActions = { + request: () => ({type: SET_PREFERRED_TIME_FORMAT}), + receive: payload => ({ + type: SET_PREFERRED_TIME_FORMAT_DONE, + payload + }), + failed: error => ({ + type: SET_PREFERRED_TIME_FORMAT_ERROR, + error + }) +}; + +export const setPreferredTimeFormat = (format: TimeFormats) => async(dispatch: Dispatch): Promise => { + const { + request, + receive, + failed + } = setPreferredTimeFormatActions; + + dispatch(request()); + + try { + await userStore.set('timeFormat', format); + + dispatch(receive({data: format})); + } catch (e) { + const {message} = transformToPortalError(e); + + dispatch(failed(message || 'An error occurred while setting the preferred time format')); + } +}; \ No newline at end of file diff --git a/src/store/middlewares/authenticationMiddleware.ts b/src/store/middlewares/authenticationMiddleware.ts index 7f83955..48075db 100644 --- a/src/store/middlewares/authenticationMiddleware.ts +++ b/src/store/middlewares/authenticationMiddleware.ts @@ -1,5 +1,5 @@ import { - authenticateCredentialsThunk, + authenticateCredentials, selectIsAuthenticated, selectIsLoading, selectApplicationId, @@ -56,7 +56,7 @@ export const authenticateRequestMiddleware: Middleware = store => next => async try { console.log('[authenticateRequest] Attempting auto-authentication'); // Use the Redux thunk to properly update the state - const authResult = await store.dispatch(authenticateCredentialsThunk({applicationId, secret}) as any); + const authResult = await store.dispatch(authenticateCredentials({applicationId, secret}) as any); if (authResult.type.endsWith('/rejected') || authResult.payload === 'Authentication failed') { console.log('[authenticateRequest] Authentication failed'); diff --git a/src/store/slices/Authentication.slice.ts b/src/store/slices/Authentication.slice.ts index 8c6eff5..cba2c77 100644 --- a/src/store/slices/Authentication.slice.ts +++ b/src/store/slices/Authentication.slice.ts @@ -60,7 +60,7 @@ export const selectHasRole = createSelector([selectAuthentication, (_, role: str export const selectIsOnline = createSelector([selectAuthentication], authentication => authentication.status === 'Online'); -const authenticateCredentialsThunk = createAsyncThunk( +const authenticateCredentials = createAsyncThunk( 'authentication/authenticate', async (credentials, {rejectWithValue}) => { try { @@ -76,7 +76,7 @@ const authenticateCredentialsThunk = createAsyncThunk { +export const signoutThunk = createAsyncThunk('authentication/signout', async (_, {rejectWithValue}) => { try { return await AuthenticationService.signout(); } catch (error) { @@ -95,8 +95,15 @@ const authenticationSlice = createSlice({ state.isLoading = action.payload; }, setCredentials: (state, action: PayloadAction<{applicationId: string; secret: string}>) => { - state.applicationId = action.payload.applicationId; - state.secret = action.payload.secret; + const {applicationId, secret} = action.payload; + + if (applicationId) { + state.applicationId = applicationId; + } + + if (secret) { + state.secret = secret; + } }, clearState: state => { state.applicationId = null; @@ -125,15 +132,14 @@ const authenticationSlice = createSlice({ }, extraReducers: builder => { builder - .addCase(authenticateCredentialsThunk.pending, state => { + .addCase(authenticateCredentials.pending, state => { state.isLoading = true; state.error = null; }) - .addCase(authenticateCredentialsThunk.fulfilled, (state, action) => { + .addCase(authenticateCredentials.fulfilled, (state, action) => { const authenticationResponse = action.payload; if (authenticationResponse.status === 'ok') { - state.applicationId = authenticationResponse.applicationId ?? null; state.sessionId = authenticationResponse.sessionId ?? null; state.isAuthenticated = true; state.roles = authenticationResponse.roles ?? []; @@ -156,7 +162,7 @@ const authenticationSlice = createSlice({ state.isLoading = false; } }) - .addCase(authenticateCredentialsThunk.rejected, (state, action) => { + .addCase(authenticateCredentials.rejected, (state, action) => { state.applicationId = null; state.sessionId = null; state.isAuthenticated = false; @@ -190,5 +196,5 @@ const authenticationSlice = createSlice({ }); export const {setUnauthorized, setIsLoading, setCredentials, clearState, setSessionId, setError} = authenticationSlice.actions; -export {authenticateCredentialsThunk}; +export {authenticateCredentials}; export default authenticationSlice.reducer; diff --git a/src/store/slices/PreferredTimeFormat.slice.ts b/src/store/slices/PreferredTimeFormat.slice.ts new file mode 100644 index 0000000..7515282 --- /dev/null +++ b/src/store/slices/PreferredTimeFormat.slice.ts @@ -0,0 +1,27 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { TimeFormats } from "utility"; + +export interface IPreferredTimeFormatState { + isLoading: boolean; + error: null | string; + timeFormat: TimeFormats; +} + +export const initialPreferredTimeFormatState: IPreferredTimeFormatState = { + isLoading: false, + error: null, + timeFormat: TimeFormats.Utc +} + +export const preferredTimeFormatSlice = createSlice({ + name: 'preferredTimeFormat', + initialState: initialPreferredTimeFormatState, + reducers: { + setPreferredTimeFormat: (state: IPreferredTimeFormatState, action: PayloadAction) => { + state.timeFormat = action.payload; + } + } +}) + +export const { setPreferredTimeFormat } = preferredTimeFormatSlice.actions; +export default preferredTimeFormatSlice.reducer; \ No newline at end of file diff --git a/src/store/store.ts b/src/store/store.ts index ebfe505..1b6aeb5 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -3,6 +3,7 @@ import AuthenticationReducer from './slices/Authentication.slice'; import ChannelsReducer from './slices/Channels.slice'; import ChannelsPublishingReducer from './action/channels-publishing'; import ScreensReducer from './action/screens'; +import PreferredTimeFormatReducer from './slices/PreferredTimeFormat.slice'; import {authenticateRequestMiddleware, loggerMiddleware, vanillaPromiseMiddleware} from './middlewares'; const store = configureStore({ @@ -10,7 +11,8 @@ const store = configureStore({ authentication: AuthenticationReducer, channels: ChannelsReducer, channelsPublishing: ChannelsPublishingReducer, - screens: ScreensReducer + screens: ScreensReducer, + preferredTimeFormat: PreferredTimeFormatReducer }, middleware: getDefaultMiddleware => getDefaultMiddleware({ diff --git a/src/utility/custom-hooks.ts b/src/utility/custom-hooks.ts index 299ef52..92c8f55 100644 --- a/src/utility/custom-hooks.ts +++ b/src/utility/custom-hooks.ts @@ -29,7 +29,7 @@ export const useComponentVisible = ( setIsComponentVisible: Dispatch>; } => { const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible); - const ref = useRef(null); + const ref = useRef(null); const handleClickOutside = () => { setIsComponentVisible(false); }; @@ -98,7 +98,7 @@ export const useLoginStatus = (applicationId: string): [boolean] => { const [isLoggedIn, setLoginStatus] = useState(false); useEffect(() => { - AuthService.hasLoginToken().then((response: boolean) => setLoginStatus(response)); + setLoginStatus(AuthService.hasLoginToken()); }, [applicationId]); return [isLoggedIn]; diff --git a/src/utility/date.ts b/src/utility/date.ts index 4440bb6..c87f793 100644 --- a/src/utility/date.ts +++ b/src/utility/date.ts @@ -26,7 +26,9 @@ export const fetchPlatformTimeFromServer = async (): Promise => { const localTimeBeforeCall = moment.utc().utc().valueOf(); const headers = new Headers(); - headers.set('Authorization', `Basic ${await userStore.get(AuthService.token, '')}`); + if (AuthService.token) { + headers.set('Authorization', `Basic ${await userStore.get(AuthService.token, '')}`); + } const response = await fetch('https://phenixrts.com/video/dash/time.txt', {headers}); const localTimeAfterCall = moment.utc().utc().valueOf(); @@ -46,8 +48,8 @@ export const getAdjustedTime = (): Moment => { export const getTimezoneAbbreviation = (date: Date): string => { const timezone = date .toString() - .match(/\(.+\)/g)[0] - .replace('(', '') + .match(/\(.+\)/g)?.[0] ?? '' + .replace('(', '') ?? '' .replace(')', ''); let abbreviation = ''; diff --git a/src/views/LoginForm/LoginForm.tsx b/src/views/LoginForm/LoginForm.tsx index abaf499..1345280 100644 --- a/src/views/LoginForm/LoginForm.tsx +++ b/src/views/LoginForm/LoginForm.tsx @@ -4,7 +4,7 @@ import {FC, useState, useEffect} from 'react'; import text from './text'; import {useAppDispatch, useAppSelector} from 'store/index'; -import {authenticateCredentialsThunk, selectIsLoading, selectError, setError, selectIsAuthenticated, setCredentials} from 'store/slices/Authentication.slice'; +import {authenticateCredentials, selectIsLoading, selectError, setError, selectIsAuthenticated, setCredentials} from 'store/slices/Authentication.slice'; import { LoginFormBackground, LogoContainer, @@ -55,7 +55,7 @@ export const LoginForm: FC = () => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); dispatch(setCredentials({applicationId, secret})); - dispatch(authenticateCredentialsThunk({applicationId, secret})); + dispatch(authenticateCredentials({applicationId, secret})); }; const handleInputChange = (setter: (value: string) => void) => (e: React.ChangeEvent) => {