diff --git a/opentrons-ai-client/src/App.test.tsx b/opentrons-ai-client/src/App.test.tsx index 859bb488f0e..ec61b02472c 100644 --- a/opentrons-ai-client/src/App.test.tsx +++ b/opentrons-ai-client/src/App.test.tsx @@ -1,22 +1,13 @@ -import { fireEvent, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, it, vi, beforeEach, expect } from 'vitest' -import * as auth0 from '@auth0/auth0-react' import { renderWithProviders } from './__testing-utils__' import { i18n } from './i18n' -import { SidePanel } from './molecules/SidePanel' -import { MainContentContainer } from './organisms/MainContentContainer' -import { Loading } from './molecules/Loading' import { App } from './App' +import { OpentronsAI } from './OpentronsAI' -vi.mock('@auth0/auth0-react') - -const mockLogout = vi.fn() - -vi.mock('./molecules/SidePanel') -vi.mock('./organisms/MainContentContainer') -vi.mock('./molecules/Loading') +vi.mock('./OpentronsAI') const render = (): ReturnType => { return renderWithProviders(, { @@ -26,42 +17,11 @@ const render = (): ReturnType => { describe('App', () => { beforeEach(() => { - vi.mocked(SidePanel).mockReturnValue(
mock SidePanel
) - vi.mocked(MainContentContainer).mockReturnValue( -
mock MainContentContainer
- ) - vi.mocked(Loading).mockReturnValue(
mock Loading
) - }) - - it('should render loading screen when isLoading is true', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: false, - isLoading: true, - }) - render() - screen.getByText('mock Loading') - }) - - it('should render text', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - }) - render() - screen.getByText('mock SidePanel') - screen.getByText('mock MainContentContainer') - screen.getByText('Logout') + vi.mocked(OpentronsAI).mockReturnValue(
mock OpentronsAI
) }) - it('should call a mock function when clicking logout button', () => { - ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: mockLogout, - }) + it('should render OpentronsAI', () => { render() - const logoutButton = screen.getByText('Logout') - fireEvent.click(logoutButton) - expect(mockLogout).toHaveBeenCalled() + expect(screen.getByText('mock OpentronsAI')).toBeInTheDocument() }) }) diff --git a/opentrons-ai-client/src/App.tsx b/opentrons-ai-client/src/App.tsx index 263ea02c844..104977150fc 100644 --- a/opentrons-ai-client/src/App.tsx +++ b/opentrons-ai-client/src/App.tsx @@ -1,82 +1,5 @@ -import { useEffect } from 'react' -import { useAuth0 } from '@auth0/auth0-react' -import { useTranslation } from 'react-i18next' -import { useForm, FormProvider } from 'react-hook-form' -import { useAtom } from 'jotai' -import { - COLORS, - Flex, - Link as LinkButton, - POSITION_ABSOLUTE, - POSITION_RELATIVE, - TYPOGRAPHY, -} from '@opentrons/components' - -import { tokenAtom } from './resources/atoms' -import { useGetAccessToken } from './resources/hooks' -import { SidePanel } from './molecules/SidePanel' -import { Loading } from './molecules/Loading' -import { MainContentContainer } from './organisms/MainContentContainer' - -export interface InputType { - userPrompt: string -} +import { OpentronsAI } from './OpentronsAI' export function App(): JSX.Element | null { - const { t } = useTranslation('protocol_generator') - const { isAuthenticated, logout, isLoading, loginWithRedirect } = useAuth0() - const [, setToken] = useAtom(tokenAtom) - const { getAccessToken } = useGetAccessToken() - - const fetchAccessToken = async (): Promise => { - try { - const accessToken = await getAccessToken() - setToken(accessToken) - } catch (error) { - console.error('Error fetching access token:', error) - } - } - const methods = useForm({ - defaultValues: { - userPrompt: '', - }, - }) - - useEffect(() => { - if (!isAuthenticated && !isLoading) { - void loginWithRedirect() - } - if (isAuthenticated) { - void fetchAccessToken() - } - }, [isAuthenticated, isLoading, loginWithRedirect]) - - if (isLoading) { - return - } - - if (!isAuthenticated) { - return null - } - - return ( - - - logout()} - textDecoration={TYPOGRAPHY.textDecorationUnderline} - > - {t('logout')} - - - - - - - - ) + return } diff --git a/opentrons-ai-client/src/OpentronsAI.test.tsx b/opentrons-ai-client/src/OpentronsAI.test.tsx new file mode 100644 index 00000000000..68d604edf07 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAI.test.tsx @@ -0,0 +1,82 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach } from 'vitest' +import * as auth0 from '@auth0/auth0-react' + +import { renderWithProviders } from './__testing-utils__' +import { i18n } from './i18n' +import { Loading } from './molecules/Loading' + +import { OpentronsAI } from './OpentronsAI' +import { Landing } from './pages/Landing' +import { useGetAccessToken } from './resources/hooks' +import { Header } from './molecules/Header' +import { Footer } from './molecules/Footer' + +vi.mock('@auth0/auth0-react') + +vi.mock('./pages/Landing') +vi.mock('./molecules/Header') +vi.mock('./molecules/Footer') +vi.mock('./molecules/Loading') +vi.mock('./resources/hooks/useGetAccessToken') +vi.mock('./analytics/mixpanel') + +const mockUseTrackEvent = vi.fn() + +vi.mock('./resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +const render = (): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('OpentronsAI', () => { + beforeEach(() => { + vi.mocked(useGetAccessToken).mockReturnValue({ + getAccessToken: vi.fn().mockResolvedValue('mock access token'), + }) + vi.mocked(Landing).mockReturnValue(
mock Landing page
) + vi.mocked(Loading).mockReturnValue(
mock Loading
) + vi.mocked(Header).mockReturnValue(
mock Header component
) + vi.mocked(Footer).mockReturnValue(
mock Footer component
) + }) + + it('should render loading screen when isLoading is true', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: false, + isLoading: true, + }) + render() + screen.getByText('mock Loading') + }) + + it('should render text', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + render() + screen.getByText('mock Landing page') + }) + + it('should render Header component', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + render() + screen.getByText('mock Header component') + }) + + it('should render Footer component', () => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + }) + render() + screen.getByText('mock Footer component') + }) +}) diff --git a/opentrons-ai-client/src/OpentronsAI.tsx b/opentrons-ai-client/src/OpentronsAI.tsx new file mode 100644 index 00000000000..621c2453e50 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAI.tsx @@ -0,0 +1,90 @@ +import { HashRouter } from 'react-router-dom' +import { + DIRECTION_COLUMN, + Flex, + OVERFLOW_AUTO, + COLORS, + ALIGN_CENTER, +} from '@opentrons/components' +import { OpentronsAIRoutes } from './OpentronsAIRoutes' +import { useAuth0 } from '@auth0/auth0-react' +import { useAtom } from 'jotai' +import { useEffect } from 'react' +import { Loading } from './molecules/Loading' +import { mixpanelAtom, tokenAtom } from './resources/atoms' +import { useGetAccessToken } from './resources/hooks' +import { initializeMixpanel } from './analytics/mixpanel' +import { useTrackEvent } from './resources/hooks/useTrackEvent' +import { Header } from './molecules/Header' +import { CLIENT_MAX_WIDTH } from './resources/constants' +import { Footer } from './molecules/Footer' + +export function OpentronsAI(): JSX.Element | null { + const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0() + const [, setToken] = useAtom(tokenAtom) + const [mixpanel] = useAtom(mixpanelAtom) + const { getAccessToken } = useGetAccessToken() + const trackEvent = useTrackEvent() + + initializeMixpanel(mixpanel) + + const fetchAccessToken = async (): Promise => { + try { + const accessToken = await getAccessToken() + setToken(accessToken) + } catch (error) { + console.error('Error fetching access token:', error) + } + } + + useEffect(() => { + if (!isAuthenticated && !isLoading) { + void loginWithRedirect() + } + if (isAuthenticated) { + void fetchAccessToken() + } + }, [isAuthenticated, isLoading, loginWithRedirect]) + + useEffect(() => { + if (isAuthenticated) { + trackEvent({ name: 'user-login', properties: {} }) + } + }, [isAuthenticated]) + + if (isLoading) { + return + } + + if (!isAuthenticated) { + return null + } + + return ( +
+ +
+ + + + + + + +
+ +
+ ) +} diff --git a/opentrons-ai-client/src/OpentronsAIRoutes.tsx b/opentrons-ai-client/src/OpentronsAIRoutes.tsx new file mode 100644 index 00000000000..630429c2aa1 --- /dev/null +++ b/opentrons-ai-client/src/OpentronsAIRoutes.tsx @@ -0,0 +1,39 @@ +import { Route, Navigate, Routes } from 'react-router-dom' +import { Landing } from './pages/Landing' + +import type { RouteProps } from './resources/types' + +const opentronsAIRoutes: RouteProps[] = [ + // replace Landing with the correct component + { + Component: Landing, + name: 'Create A New Protocol', + navLinkTo: '/new-protocol', + path: '/new-protocol', + }, + { + Component: Landing, + name: 'Update An Existing Protocol', + navLinkTo: '/update-protocol', + path: '/update-protocol', + }, +] + +export function OpentronsAIRoutes(): JSX.Element { + const landingPage: RouteProps = { + Component: Landing, + name: 'Landing', + navLinkTo: '/', + path: '/', + } + const allRoutes: RouteProps[] = [...opentronsAIRoutes, landingPage] + + return ( + + {allRoutes.map(({ Component, path }: RouteProps) => ( + } /> + ))} + } /> + + ) +} diff --git a/opentrons-ai-client/src/analytics/mixpanel.ts b/opentrons-ai-client/src/analytics/mixpanel.ts new file mode 100644 index 00000000000..eb81b72e6e3 --- /dev/null +++ b/opentrons-ai-client/src/analytics/mixpanel.ts @@ -0,0 +1,67 @@ +import mixpanel from 'mixpanel-browser' +import { getHasOptedIn } from './selectors' + +export const getIsProduction = (): boolean => + global.location.host === 'designer.opentrons.com' // UPDATE THIS TO CORRECT URL + +export type AnalyticsEvent = + | { + name: string + properties: Record + superProperties?: Record + } + | { superProperties: Record } + +// pulled in from environment at build time +const MIXPANEL_ID = process.env.OT_AI_CLIENT_MIXPANEL_ID + +const MIXPANEL_OPTS = { + // opt out by default + opt_out_tracking_by_default: true, +} + +export function initializeMixpanel(state: any): void { + const optedIn = getHasOptedIn(state) ?? false + if (MIXPANEL_ID != null) { + console.debug('Initializing Mixpanel', { optedIn }) + + mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) + setMixpanelTracking(optedIn) + trackEvent({ name: 'appOpen', properties: {} }, optedIn) // TODO IMMEDIATELY: do we want this? + } else { + console.warn('MIXPANEL_ID not found; this is a bug if build is production') + } +} + +export function trackEvent(event: AnalyticsEvent, optedIn: boolean): void { + console.debug('Trackable event', { event, optedIn }) + if (MIXPANEL_ID != null && optedIn) { + if ('superProperties' in event && event.superProperties != null) { + mixpanel.register(event.superProperties) + } + if ('name' in event && event.name != null) { + mixpanel.track(event.name, event.properties) + } + } +} + +export function setMixpanelTracking(optedIn: boolean): void { + if (MIXPANEL_ID != null) { + if (optedIn) { + console.debug('User has opted into analytics; tracking with Mixpanel') + mixpanel.opt_in_tracking() + // Register "super properties" which are included with all events + mixpanel.register({ + appVersion: 'test', // TODO update this? + // NOTE(IL, 2020): Since PD may be in the same Mixpanel project as other OT web apps, this 'appName' property is intended to distinguish it + appName: 'opentronsAIClient', + }) + } else { + console.debug( + 'User has opted out of analytics; stopping Mixpanel tracking' + ) + mixpanel.opt_out_tracking() + mixpanel.reset() + } + } +} diff --git a/opentrons-ai-client/src/analytics/selectors.ts b/opentrons-ai-client/src/analytics/selectors.ts new file mode 100644 index 00000000000..b55165f3049 --- /dev/null +++ b/opentrons-ai-client/src/analytics/selectors.ts @@ -0,0 +1,2 @@ +export const getHasOptedIn = (state: any): boolean | null => + state.analytics.hasOptedIn diff --git a/opentrons-ai-client/src/assets/images/welcome_dashboard.png b/opentrons-ai-client/src/assets/images/welcome_dashboard.png new file mode 100644 index 00000000000..c6f84b4429b Binary files /dev/null and b/opentrons-ai-client/src/assets/images/welcome_dashboard.png differ diff --git a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json index 1d69984c345..6bf5b633936 100644 --- a/opentrons-ai-client/src/assets/localization/en/protocol_generator.json +++ b/opentrons-ai-client/src/assets/localization/en/protocol_generator.json @@ -10,6 +10,12 @@ "got_feedback": "Got feedback? We love to hear it.", "key_info": "Here are some key pieces of information to provide in your prompt:", "labware_and_tipracks": "Labware and tip racks: Use names from the Opentrons Labware Library.", + "landing_page_body": "Get started building a prompt that will generate a Python protocol that you can use on your Opentrons robot. OpentronsAI lets you create and optimize your protocol by responding in natural language.", + "landing_page_body_mobile": "Use a desktop browser to use OpentronsAI.", + "landing_page_button_new_protocol": "Create a new protocol", + "landing_page_button_update_protocol": "Update an existing protocol", + "landing_page_heading": "Welcome to OpentronsAI", + "landing_page_image_alt": "welcome image", "liquid_locations": "Liquid locations: Describe where liquids should go in the labware.", "loading": "Loading...", "login": "Login", diff --git a/opentrons-ai-client/src/molecules/Footer/index.tsx b/opentrons-ai-client/src/molecules/Footer/index.tsx index 5ef44bc733f..c8bbc4054fd 100644 --- a/opentrons-ai-client/src/molecules/Footer/index.tsx +++ b/opentrons-ai-client/src/molecules/Footer/index.tsx @@ -44,7 +44,7 @@ export function Footer(): JSX.Element { > ({ + useTrackEvent: () => mockUseTrackEvent, +})) const render = (): ReturnType => { return renderWithProviders(
, { @@ -11,6 +20,14 @@ const render = (): ReturnType => { } describe('Header', () => { + beforeEach(() => { + ;(auth0 as any).useAuth0 = vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: mockLogout, + }) + }) + it('should render Header component', () => { render() screen.getByText('Opentrons') @@ -20,4 +37,21 @@ describe('Header', () => { render() screen.getByText('Logout') }) + + it('should logout when log out button is clicked', () => { + render() + const logoutButton = screen.getByText('Logout') + fireEvent.click(logoutButton) + expect(mockLogout).toHaveBeenCalled() + }) + + it('should track logout event when log out button is clicked', () => { + render() + const logoutButton = screen.getByText('Logout') + fireEvent.click(logoutButton) + expect(mockUseTrackEvent).toHaveBeenCalledWith({ + name: 'user-logout', + properties: {}, + }) + }) }) diff --git a/opentrons-ai-client/src/molecules/Header/index.tsx b/opentrons-ai-client/src/molecules/Header/index.tsx index e909aeaf691..8221aa03e81 100644 --- a/opentrons-ai-client/src/molecules/Header/index.tsx +++ b/opentrons-ai-client/src/molecules/Header/index.tsx @@ -10,15 +10,19 @@ import { COLORS, POSITION_RELATIVE, ALIGN_CENTER, + JUSTIFY_CENTER, JUSTIFY_SPACE_BETWEEN, } from '@opentrons/components' import { useAuth0 } from '@auth0/auth0-react' +import { CLIENT_MAX_WIDTH } from '../../resources/constants' +import { useTrackEvent } from '../../resources/hooks/useTrackEvent' const HeaderBar = styled(Flex)` position: ${POSITION_RELATIVE}; background-color: ${COLORS.white}; width: 100%; align-items: ${ALIGN_CENTER}; + justify-content: ${JUSTIFY_CENTER}; height: 60px; ` @@ -27,6 +31,7 @@ const HeaderBarContent = styled(Flex)` padding: 18px 32px; justify-content: ${JUSTIFY_SPACE_BETWEEN}; width: 100%; + max-width: ${CLIENT_MAX_WIDTH}; ` const HeaderGradientTitle = styled(StyledText)` @@ -48,6 +53,12 @@ const LogoutButton = styled(LinkButton)` export function Header(): JSX.Element { const { t } = useTranslation('protocol_generator') const { logout } = useAuth0() + const trackEvent = useTrackEvent() + + function handleLogout(): void { + logout() + trackEvent({ name: 'user-logout', properties: {} }) + } return ( @@ -56,7 +67,7 @@ export function Header(): JSX.Element { {t('opentrons')} {t('ai')} - logout()}>{t('logout')} + {t('logout')} ) diff --git a/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx b/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx new file mode 100644 index 00000000000..a90807878eb --- /dev/null +++ b/opentrons-ai-client/src/pages/Landing/__tests__/Landing.test.tsx @@ -0,0 +1,107 @@ +import { screen } from '@testing-library/react' +import { describe, it, vi, beforeEach, expect } from 'vitest' +import { renderWithProviders } from '../../../__testing-utils__' +import type { NavigateFunction } from 'react-router-dom' + +import { Landing } from '../index' +import { i18n } from '../../../i18n' + +const mockNavigate = vi.fn() +const mockUseTrackEvent = vi.fn() + +vi.mock('../../../resources/hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +vi.mock('react-router-dom', async importOriginal => { + const reactRouterDom = await importOriginal() + return { + ...reactRouterDom, + useNavigate: () => mockNavigate, + } +}) + +vi.mock('../../../hooks/useTrackEvent', () => ({ + useTrackEvent: () => mockUseTrackEvent, +})) + +const render = () => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('Landing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render', () => { + render() + expect(screen.getByText('Welcome to OpentronsAI')).toBeInTheDocument() + }) + + it('should render the image, heading and body text', () => { + render() + expect(screen.getByAltText('welcome image')).toBeInTheDocument() + expect(screen.getByText('Welcome to OpentronsAI')).toBeInTheDocument() + expect( + screen.getByText( + 'Get started building a prompt that will generate a Python protocol that you can use on your Opentrons robot. OpentronsAI lets you create and optimize your protocol by responding in natural language.' + ) + ).toBeInTheDocument() + }) + + it('should render create and update protocol buttons', () => { + render() + expect(screen.getByText('Create a new protocol')).toBeInTheDocument() + expect(screen.getByText('Update an existing protocol')).toBeInTheDocument() + }) + + it('should render the mobile body text if the screen width is less than 768px', () => { + vi.stubGlobal('innerWidth', 767) + window.dispatchEvent(new Event('resize')) + render() + expect( + screen.getByText('Use a desktop browser to use OpentronsAI.') + ).toBeInTheDocument() + + vi.unstubAllGlobals() + }) + + it('should redirect to the new protocol page when the create a new protocol button is clicked', () => { + render() + const createProtocolButton = screen.getByText('Create a new protocol') + createProtocolButton.click() + expect(mockNavigate).toHaveBeenCalledWith('/new-protocol') + }) + + it('should redirect to the update protocol page when the update an existing protocol button is clicked', () => { + render() + const updateProtocolButton = screen.getByText('Update an existing protocol') + updateProtocolButton.click() + expect(mockNavigate).toHaveBeenCalledWith('/update-protocol') + }) + + it('should track new protocol event when new protocol button is clicked', () => { + render() + const createProtocolButton = screen.getByText('Create a new protocol') + createProtocolButton.click() + + expect(mockUseTrackEvent).toHaveBeenCalledWith({ + name: 'create-new-protocol', + properties: {}, + }) + }) + + it('should track logout event when log out button is clicked', () => { + render() + const updateProtocolButton = screen.getByText('Update an existing protocol') + updateProtocolButton.click() + + expect(mockUseTrackEvent).toHaveBeenCalledWith({ + name: 'update-protocol', + properties: {}, + }) + }) +}) diff --git a/opentrons-ai-client/src/pages/Landing/index.tsx b/opentrons-ai-client/src/pages/Landing/index.tsx new file mode 100644 index 00000000000..b464ad5ff29 --- /dev/null +++ b/opentrons-ai-client/src/pages/Landing/index.tsx @@ -0,0 +1,89 @@ +import { + ALIGN_CENTER, + BORDERS, + COLORS, + DIRECTION_COLUMN, + Flex, + JUSTIFY_CENTER, + LargeButton, + POSITION_RELATIVE, + SPACING, + StyledText, + TEXT_ALIGN_CENTER, +} from '@opentrons/components' +import welcomeImage from '../../assets/images/welcome_dashboard.png' +import { useTranslation } from 'react-i18next' +import { useIsMobile } from '../../resources/hooks/useIsMobile' +import { useNavigate } from 'react-router-dom' +import { useTrackEvent } from '../../resources/hooks/useTrackEvent' + +export interface InputType { + userPrompt: string +} + +export function Landing(): JSX.Element | null { + const navigate = useNavigate() + const { t } = useTranslation('protocol_generator') + const isMobile = useIsMobile() + const trackEvent = useTrackEvent() + + function handleCreateNewProtocol(): void { + trackEvent({ name: 'create-new-protocol', properties: {} }) + navigate('/new-protocol') + } + + function handleUpdateProtocol(): void { + trackEvent({ name: 'update-protocol', properties: {} }) + navigate('/update-protocol') + } + + return ( + + + {t('landing_page_image_alt')} + + + {t('landing_page_heading')} + + + {!isMobile ? t('landing_page_body') : t('landing_page_body_mobile')} + + + {!isMobile && ( + <> + + + + )} + + + ) +} diff --git a/opentrons-ai-client/src/resources/atoms.ts b/opentrons-ai-client/src/resources/atoms.ts index 2065f7e89e2..73d45fb165b 100644 --- a/opentrons-ai-client/src/resources/atoms.ts +++ b/opentrons-ai-client/src/resources/atoms.ts @@ -1,6 +1,6 @@ // jotai's atoms import { atom } from 'jotai' -import type { Chat, ChatData } from './types' +import type { Chat, ChatData, Mixpanel } from './types' /** ChatDataAtom is for chat data (user prompt and response from OpenAI API) */ export const chatDataAtom = atom([]) @@ -8,3 +8,7 @@ export const chatDataAtom = atom([]) export const chatHistoryAtom = atom([]) export const tokenAtom = atom(null) + +export const mixpanelAtom = atom({ + analytics: { hasOptedIn: true }, // TODO: set to false +}) diff --git a/opentrons-ai-client/src/resources/constants.ts b/opentrons-ai-client/src/resources/constants.ts index 834e58cb1db..c5e2f8826c6 100644 --- a/opentrons-ai-client/src/resources/constants.ts +++ b/opentrons-ai-client/src/resources/constants.ts @@ -19,3 +19,5 @@ export const LOCAL_AUTH0_CLIENT_ID = 'PcuD1wEutfijyglNeRBi41oxsKJ1HtKw' export const LOCAL_AUTH0_AUDIENCE = 'sandbox-ai-api' export const LOCAL_AUTH0_DOMAIN = 'identity.auth-dev.opentrons.com' export const LOCAL_END_POINT = 'http://localhost:8000/api/chat/completion' + +export const CLIENT_MAX_WIDTH = '1440px' diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts b/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts new file mode 100644 index 00000000000..bd1374e64b1 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/__tests__/useIsMobile.test.ts @@ -0,0 +1,18 @@ +import { describe, it, vi, expect } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useIsMobile } from '../useIsMobile' + +describe('useIsMobile', () => { + it('should return true if the window width is less than 768px', () => { + vi.stubGlobal('innerWidth', 767) + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(true) + }) + + it('should return false if the window width is greater than 768px', () => { + vi.stubGlobal('innerWidth', 769) + window.dispatchEvent(new Event('resize')) + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(false) + }) +}) diff --git a/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx new file mode 100644 index 00000000000..fab96155156 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/__tests__/useTrackEvent.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, vi, expect, afterEach } from 'vitest' +import { trackEvent } from '../../../analytics/mixpanel' +import { useTrackEvent } from '../useTrackEvent' +import { renderHook } from '@testing-library/react' +import { mixpanelAtom } from '../../atoms' +import type { AnalyticsEvent } from '../../../analytics/mixpanel' +import type { Mixpanel } from '../../types' +import { TestProvider } from '../../utils/testUtils' + +vi.mock('../../../analytics/mixpanel', () => ({ + trackEvent: vi.fn(), +})) + +describe('useTrackEvent', () => { + afterEach(() => { + vi.resetAllMocks() + }) + + it('should call trackEvent with the correct arguments when hasOptedIn is true', () => { + const mockMixpanelAtom: Mixpanel = { + analytics: { + hasOptedIn: true, + }, + } + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook(() => useTrackEvent(), { wrapper }) + + const event: AnalyticsEvent = { name: 'test_event', properties: {} } + result.current(event) + + expect(trackEvent).toHaveBeenCalledWith(event, true) + }) + + it('should call trackEvent with the correct arguments when hasOptedIn is false', () => { + const mockMixpanelAtomFalse: Mixpanel = { + analytics: { + hasOptedIn: false, + }, + } + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + + const { result } = renderHook(() => useTrackEvent(), { wrapper }) + + const event: AnalyticsEvent = { name: 'test_event', properties: {} } + result.current(event) + + expect(trackEvent).toHaveBeenCalledWith(event, false) + }) +}) diff --git a/opentrons-ai-client/src/resources/hooks/useIsMobile.ts b/opentrons-ai-client/src/resources/hooks/useIsMobile.ts new file mode 100644 index 00000000000..5c0f4933b75 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/useIsMobile.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react' + +const MOBILE_BREAKPOINT = 768 + +export const useIsMobile = (): boolean => { + const [isMobile, setIsMobile] = useState( + window.innerWidth < MOBILE_BREAKPOINT + ) + + useEffect(() => { + const handleResize = (): void => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + return isMobile +} diff --git a/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts new file mode 100644 index 00000000000..bdd9eb1c470 --- /dev/null +++ b/opentrons-ai-client/src/resources/hooks/useTrackEvent.ts @@ -0,0 +1,16 @@ +import { useAtom } from 'jotai' +import { trackEvent } from '../../analytics/mixpanel' +import { mixpanelAtom } from '../atoms' +import type { AnalyticsEvent } from '../types' + +/** + * React hook to send an analytics tracking event directly from a component + * + * @returns {AnalyticsEvent => void} track event function + */ +export function useTrackEvent(): (e: AnalyticsEvent) => void { + const [mixpanel] = useAtom(mixpanelAtom) + return event => { + trackEvent(event, mixpanel?.analytics?.hasOptedIn ?? false) + } +} diff --git a/opentrons-ai-client/src/resources/types.ts b/opentrons-ai-client/src/resources/types.ts index d2758c966ae..067c1ef9764 100644 --- a/opentrons-ai-client/src/resources/types.ts +++ b/opentrons-ai-client/src/resources/types.ts @@ -16,3 +16,29 @@ export interface Chat { /** content ChatGPT API return or user prompt */ content: string } + +export interface RouteProps { + /** the component rendered by a route match + * drop developed components into slots held by placeholder div components + * */ + Component: React.FC + /** a route/page name to render in the nav bar + */ + name: string + /** the path for navigation linking, for example to push to a default tab + */ + path: string + navLinkTo: string +} + +export interface Mixpanel { + analytics: { + hasOptedIn: boolean + } +} + +export interface AnalyticsEvent { + name: string + properties: Record + superProperties?: Record +} diff --git a/opentrons-ai-client/src/resources/utils/testUtils.tsx b/opentrons-ai-client/src/resources/utils/testUtils.tsx new file mode 100644 index 00000000000..954307bd391 --- /dev/null +++ b/opentrons-ai-client/src/resources/utils/testUtils.tsx @@ -0,0 +1,29 @@ +import { Provider } from 'jotai' +import { useHydrateAtoms } from 'jotai/utils' + +interface HydrateAtomsProps { + initialValues: Array<[any, any]> + children: React.ReactNode +} + +interface TestProviderProps { + initialValues: Array<[any, any]> + children: React.ReactNode +} + +export const HydrateAtoms = ({ + initialValues, + children, +}: HydrateAtomsProps): React.ReactNode => { + useHydrateAtoms(initialValues) + return children +} + +export const TestProvider = ({ + initialValues, + children, +}: TestProviderProps): React.ReactNode => ( + + {children} + +)