diff --git a/client/src/modules/app/routes/constants/routes.ts b/client/src/modules/app/routes/constants/routes.ts index bda6ff06..cba1d32e 100644 --- a/client/src/modules/app/routes/constants/routes.ts +++ b/client/src/modules/app/routes/constants/routes.ts @@ -14,4 +14,9 @@ export enum ROUTES_HOME_V1 { export enum ROUTES_SETTINGS_V1 { PROFILE = '/profile', + INTEGRATIONS = '/integrations', +} + +export enum ROUTES_SETTINGS_INTEGRATIONS_V1 { + OPENAI = '/openai', } diff --git a/client/src/modules/settings/modules/index.tsx b/client/src/modules/settings/modules/index.tsx index 05321729..a00cd769 100644 --- a/client/src/modules/settings/modules/index.tsx +++ b/client/src/modules/settings/modules/index.tsx @@ -1,3 +1,9 @@ import { lazy } from 'react'; export const ProfileLazyComponentV1 = lazy(() => import('./profile/v1')); +export const IntegrationsLazyComponentV1 = lazy( + () => import('./integrations/v1') +); +export const OpenAILazyComponentV1 = lazy( + () => import('./integrations/modules/openai/v1') +); diff --git a/client/src/modules/settings/modules/integrations/modules/openai/v1/hooks/index.ts b/client/src/modules/settings/modules/integrations/modules/openai/v1/hooks/index.ts new file mode 100644 index 00000000..6685e001 --- /dev/null +++ b/client/src/modules/settings/modules/integrations/modules/openai/v1/hooks/index.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from 'react'; +import { useNotifications } from '../../../../../../../../shared/hooks/use-notification'; + +const useOpenAIIntegrationV1 = () => { + const { addNotification } = useNotifications(); + + const [apiKey, setApiKey] = useState(''); + const [isConnected, setIsConnected] = useState(false); + const [loading, setLoading] = useState(false); + + const handleConnect = useCallback(async () => { + setLoading(true); + try { + // TODO: Implement the logic to connect to OpenAI + setIsConnected(true); + addNotification({ + message: 'OpenAI connected successfully', + type: 'success', + }); + } catch (error) { + console.error(error); + addNotification({ + message: 'Failed to connect to OpenAI', + type: 'error', + }); + } finally { + setLoading(false); + } + }, [addNotification]); + + const handleDisconnect = useCallback(async () => { + setLoading(true); + try { + // TODO: Implement the logic to disconnect from OpenAI + setIsConnected(false); + setApiKey(''); + addNotification({ + message: 'OpenAI disconnected successfully', + type: 'success', + }); + } catch (error) { + console.error(error); + addNotification({ + message: 'Failed to disconnect from OpenAI', + type: 'error', + }); + } finally { + setLoading(false); + } + }, [addNotification]); + + return { + apiKey, + setApiKey, + isConnected, + loading, + handleConnect, + handleDisconnect, + }; +}; + +export default useOpenAIIntegrationV1; diff --git a/client/src/modules/settings/modules/integrations/modules/openai/v1/index.tsx b/client/src/modules/settings/modules/integrations/modules/openai/v1/index.tsx new file mode 100644 index 00000000..bf1ca1ed --- /dev/null +++ b/client/src/modules/settings/modules/integrations/modules/openai/v1/index.tsx @@ -0,0 +1,111 @@ +import { Box, Button, Stack, CircularProgress } from '@mui/material'; +import VpnKeyIcon from '@mui/icons-material/VpnKey'; +import InputBox from '../../../../../../../shared/components/atoms/input-box'; +import A2ZTypography from '../../../../../../../shared/components/atoms/typography'; +import useOpenAIIntegrationV1 from './hooks'; + +const OpenAI = () => { + const { + apiKey, + setApiKey, + isConnected, + loading, + handleConnect, + handleDisconnect, + } = useOpenAIIntegrationV1(); + + return ( + + + + + + } + disabled={isConnected} + autoComplete="off" + autoFocus={!isConnected && !loading} + sx={{ + width: { xs: '100%', md: '50%' }, + '& .MuiOutlinedInput-root': { + bgcolor: 'background.paper', + }, + }} + slotProps={{ + htmlInput: { + onChange: (e: React.ChangeEvent) => { + setApiKey(e.target.value); + }, + }, + }} + /> + + + + + + + + + ); +}; + +export default OpenAI; diff --git a/client/src/modules/settings/modules/integrations/v1/components/integrations-header.tsx b/client/src/modules/settings/modules/integrations/v1/components/integrations-header.tsx new file mode 100644 index 00000000..8f99756c --- /dev/null +++ b/client/src/modules/settings/modules/integrations/v1/components/integrations-header.tsx @@ -0,0 +1,70 @@ +import { Box, IconButton, useTheme } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import A2ZTypography from '../../../../../../shared/components/atoms/typography'; +import { HEADER_HEIGHT } from '../../../../../../shared/components/organisms/header/constants'; +import { useDevice } from '../../../../../../shared/hooks/use-device'; +import { useCustomNavigate } from '../../../../../../shared/hooks/use-custom-navigate'; +import { + ROUTES_SETTINGS_V1, + ROUTES_V1, +} from '../../../../../app/routes/constants/routes'; + +interface IntegrationsHeaderProps { + title: string; +} + +const IntegrationsHeader = ({ title }: IntegrationsHeaderProps) => { + const theme = useTheme(); + const { isMobileOrTablet } = useDevice(); + const navigate = useCustomNavigate(); + + const handleBackClick = () => { + navigate({ + pathname: `${ROUTES_V1.SETTINGS}${ROUTES_SETTINGS_V1.INTEGRATIONS}`, + }); + }; + + return ( + + {isMobileOrTablet && ( + + + + )} + + + ); +}; + +export default IntegrationsHeader; diff --git a/client/src/modules/settings/modules/integrations/v1/hooks/index.ts b/client/src/modules/settings/modules/integrations/v1/hooks/index.ts new file mode 100644 index 00000000..76638ccc --- /dev/null +++ b/client/src/modules/settings/modules/integrations/v1/hooks/index.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { integrationsRoutes } from '../../../../routes'; +import { useDevice } from '../../../../../../shared/hooks/use-device'; + +const useIntegrationsSettingsV1 = () => { + const location = useLocation(); + const { isMobileOrTablet } = useDevice(); + + const { integrations, routes } = integrationsRoutes({ isMobileOrTablet }); + + const integrationId = useMemo(() => { + const pathParts = location.pathname.split('/'); + const integrationsIndex = pathParts.findIndex( + part => part === 'integrations' + ); + if (integrationsIndex !== -1 && pathParts[integrationsIndex + 1]) { + const slug = pathParts[integrationsIndex + 1]; + return integrations.find( + integration => integration.integrationSlug === slug + )?.id; + } + return null; + }, [location.pathname, integrations]); + + const activeIntegration = useMemo(() => { + if (!integrationId) return undefined; + return integrations.find(integration => integration.id === integrationId); + }, [integrationId, integrations]); + + return { + integrations, + routes, + integrationId, + activeIntegration, + }; +}; + +export default useIntegrationsSettingsV1; diff --git a/client/src/modules/settings/modules/integrations/v1/index.tsx b/client/src/modules/settings/modules/integrations/v1/index.tsx new file mode 100644 index 00000000..d76c8c1e --- /dev/null +++ b/client/src/modules/settings/modules/integrations/v1/index.tsx @@ -0,0 +1,273 @@ +import { Box, ButtonBase, useTheme } from '@mui/material'; +import { Routes } from 'react-router-dom'; +import LockIcon from '@mui/icons-material/Lock'; +import { SETTINGS_SIDEBAR_WIDTH } from '../../../v1/constants'; +import useIntegrationsSettingsV1 from './hooks'; +import A2ZTypography from '../../../../../shared/components/atoms/typography'; +import { useCustomNavigate } from '../../../../../shared/hooks/use-custom-navigate'; +import { useDevice } from '../../../../../shared/hooks/use-device'; +import { + ROUTES_SETTINGS_V1, + ROUTES_V1, +} from '../../../../app/routes/constants/routes'; +import IntegrationsHeader from './components/integrations-header'; +import { IntegrationSettingType } from '../../../v1/typings'; + +const Integrations = () => { + const theme = useTheme(); + const { isMobileOrTablet } = useDevice(); + const navigate = useCustomNavigate(); + const { integrations, routes, integrationId, activeIntegration } = + useIntegrationsSettingsV1(); + + // On mobile, show sidebar only when not on a detail page + const showSidebarOnMobile = !isMobileOrTablet || !activeIntegration; + + return ( + + + + {integrations.map( + (integration: IntegrationSettingType, index: number) => { + const { + id, + integrationSlug, + name, + icon, + description, + locked = false, + } = integration; + const absolutePath = `${ROUTES_V1.SETTINGS}${ROUTES_SETTINGS_V1.INTEGRATIONS}/${integrationSlug}`; + const isTabActive = integrationId === id; + + return ( + + key={id || index} + component="div" + sx={{ + width: '100%', + p: 2, + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + rowGap: 0.25, + borderBottom: + index < integrations.length - 1 + ? `1px solid ${theme.palette.divider}` + : 'none', + bgcolor: isTabActive + ? theme.palette.mode === 'dark' + ? 'rgba(59, 130, 246, 0.1)' + : 'rgba(240, 245, 240, 1)' + : 'transparent', + boxShadow: 'none', + outline: 'none', + transition: 'background-color 200ms ease-in-out', + '&:hover': { + bgcolor: isTabActive + ? theme.palette.mode === 'dark' + ? 'rgba(59, 130, 246, 0.15)' + : 'rgba(240, 245, 240, 0.68)' + : 'action.hover', + boxShadow: 'none', + outline: 'none', + }, + }} + disabled={locked} + onClick={() => { + if (locked) { + return; + } + navigate({ pathname: absolutePath }); + }} + > + + + + {icon} + {locked && ( + + )} + + + + + {description && ( + + )} + + ); + } + )} + + + + + {activeIntegration && ( + + )} + + {routes} + + + + ); +}; + +export default Integrations; diff --git a/client/src/modules/settings/routes/index.tsx b/client/src/modules/settings/routes/index.tsx index bec90c3e..8cfd5d4b 100644 --- a/client/src/modules/settings/routes/index.tsx +++ b/client/src/modules/settings/routes/index.tsx @@ -1,12 +1,19 @@ import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'; +import ExtensionIcon from '@mui/icons-material/Extension'; import { ROUTES_V1, ROUTES_SETTINGS_V1, + ROUTES_SETTINGS_INTEGRATIONS_V1, } from '../../app/routes/constants/routes'; -import { SettingTabType } from '../v1/typings'; +import { IntegrationSettingType, SettingTabType } from '../v1/typings'; import { Navigate, Route } from 'react-router-dom'; import { ProtectedRoute } from '../../app/routes/auth-routes/protected-route'; -import { ProfileLazyComponentV1 } from '../modules'; +import { + ProfileLazyComponentV1, + IntegrationsLazyComponentV1, + OpenAILazyComponentV1, +} from '../modules'; +import OpenAIIcon from '../../../shared/icons/openai'; export const settingsRoutes = ({ isMobileOrTablet, @@ -21,6 +28,13 @@ export const settingsRoutes = ({ name: 'Your profile', description: 'Edit your personal details', }, + { + id: 'integrations', + icon: , + path: ROUTES_SETTINGS_V1.INTEGRATIONS, + name: 'Integrations', + description: 'Manage your integrations', + }, ]; const routes: React.ReactNode[] = [ @@ -31,6 +45,16 @@ export const settingsRoutes = ({ } />, + + } + />, !isMobileOrTablet && ( { + const integrations: IntegrationSettingType[] = [ + { + id: 'openai', + integrationSlug: 'openai', + icon: , + name: 'OpenAI', + description: 'Setup your OpenAI integration', + locked: false, + }, + ]; + + const routes: React.ReactNode[] = [ + + } + />, + + !isMobileOrTablet && ( + + } + /> + ), + ]; + + return { + integrations, + routes, + }; +}; diff --git a/client/src/modules/settings/v1/hooks/index.ts b/client/src/modules/settings/v1/hooks/index.ts index 0b4f58c7..4148f2b8 100644 --- a/client/src/modules/settings/v1/hooks/index.ts +++ b/client/src/modules/settings/v1/hooks/index.ts @@ -46,10 +46,6 @@ const useSettingsV1 = () => { }); }, [location.pathname, settings]); - const isSettingsDetailPage = useMemo(() => { - return activeSetting !== undefined; - }, [activeSetting]); - return { searchTerm, setSearchTerm, @@ -58,7 +54,6 @@ const useSettingsV1 = () => { routes, filteredSettings, activeSetting, - isSettingsDetailPage, }; }; diff --git a/client/src/modules/settings/v1/index.tsx b/client/src/modules/settings/v1/index.tsx index c08a928b..bab6f819 100644 --- a/client/src/modules/settings/v1/index.tsx +++ b/client/src/modules/settings/v1/index.tsx @@ -18,11 +18,10 @@ const Settings = () => { filteredSettings, routes, activeSetting, - isSettingsDetailPage, } = useSettingsV1(); // On mobile, show sidebar only when not on a detail page - const showSidebarOnMobile = !isMobileOrTablet || !isSettingsDetailPage; + const showSidebarOnMobile = !isMobileOrTablet || !activeSetting; return ( { sx={{ height: '100%', width: { - xs: isSettingsDetailPage ? '100%' : 0, + xs: activeSetting ? '100%' : 0, md: `calc(100% - ${SETTINGS_SIDEBAR_WIDTH}px)`, }, minWidth: { - xs: isSettingsDetailPage ? '100%' : 0, + xs: activeSetting ? '100%' : 0, md: `calc(100% - ${SETTINGS_SIDEBAR_WIDTH}px)`, }, maxWidth: { - xs: isSettingsDetailPage ? '100%' : 0, + xs: activeSetting ? '100%' : 0, md: `calc(100% - ${SETTINGS_SIDEBAR_WIDTH}px)`, }, display: { - xs: isSettingsDetailPage ? 'flex' : 'none', + xs: activeSetting ? 'flex' : 'none', md: 'flex', }, flexDirection: 'column', diff --git a/client/src/modules/settings/v1/typings/index.ts b/client/src/modules/settings/v1/typings/index.ts index 5788dc1e..2bef09a6 100644 --- a/client/src/modules/settings/v1/typings/index.ts +++ b/client/src/modules/settings/v1/typings/index.ts @@ -12,3 +12,12 @@ export interface SettingTabType { disabled?: boolean; feature?: string; } + +export interface IntegrationSettingType { + id: string; + integrationSlug: string; + icon: ReactNode; + name: string; + description?: string; + locked?: boolean; +} diff --git a/client/src/shared/icons/openai.tsx b/client/src/shared/icons/openai.tsx new file mode 100644 index 00000000..c9d666d3 --- /dev/null +++ b/client/src/shared/icons/openai.tsx @@ -0,0 +1,30 @@ +import { FC, SVGProps } from 'react'; + +const OpenAIIcon: FC> = ({ + color = '#7a7a7a', + width = 24, + height = 24, + ...props +}) => { + return ( + + + + ); +}; + +export default OpenAIIcon;