app-tutor-ai-consumer 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/config/rspack/rspack.config.js +4 -6
  3. package/config/rspack/utils/plugins.js +2 -2
  4. package/config/vitest/__mocks__/sparkie.tsx +6 -1
  5. package/config/vitest/vitest.config.mts +0 -1
  6. package/package.json +1 -1
  7. package/src/development-bootstrap.tsx +16 -1
  8. package/src/index.tsx +34 -15
  9. package/src/lib/contexts/index.ts +1 -0
  10. package/src/lib/contexts/shared-ref/index.ts +1 -0
  11. package/src/lib/contexts/shared-ref/shared-ref.tsx +26 -0
  12. package/src/main/main.spec.tsx +6 -3
  13. package/src/main/main.tsx +19 -1
  14. package/src/modules/cursor/service.ts +20 -2
  15. package/src/modules/global-providers/global-providers.tsx +5 -0
  16. package/src/modules/messages/components/messages-list/messages-list.tsx +12 -12
  17. package/src/modules/messages/events.ts +25 -0
  18. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +21 -0
  19. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +2 -0
  20. package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +16 -1
  21. package/src/modules/messages/store/index.ts +1 -0
  22. package/src/modules/messages/store/unread-messages-set.atom.ts +21 -0
  23. package/src/modules/messages/types.ts +4 -0
  24. package/src/modules/sparkie/service.ts +14 -4
  25. package/src/modules/thread/hooks/index.ts +1 -0
  26. package/src/modules/thread/hooks/use-subscribe-thread-closed-event/index.ts +2 -0
  27. package/src/modules/thread/hooks/use-subscribe-thread-closed-event/use-subscribe-thread-closed-event.tsx +22 -0
  28. package/src/modules/thread/index.ts +1 -0
  29. package/src/modules/widget/components/container/container.tsx +2 -0
  30. package/src/modules/widget/components/page-layout/constants.tsx +8 -0
  31. package/src/modules/widget/components/page-layout/index.ts +1 -0
  32. package/src/modules/widget/components/page-layout/page-layout.tsx +18 -3
  33. package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +3 -4
  34. package/src/modules/widget/events.ts +53 -12
  35. package/src/modules/widget/hooks/index.ts +1 -1
  36. package/src/modules/widget/hooks/use-listen-to-visibility-events/index.ts +1 -0
  37. package/src/modules/widget/hooks/use-listen-to-visibility-events/use-listen-to-visibility-events.tsx +20 -0
  38. package/src/modules/widget/store/widget-tabs.atom.ts +2 -2
  39. package/src/modules/widget/types.ts +3 -3
  40. package/src/types.ts +6 -0
  41. package/config/vitest/__mocks__/use-init-sparkie.tsx +0 -14
  42. package/src/modules/widget/hooks/use-init-sparkie/index.ts +0 -1
  43. package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +0 -20
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # [1.9.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.8.2...v1.9.0) (2025-07-17)
2
+
3
+ ### Features
4
+
5
+ - add on close listener ([c41e700](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/c41e700241ac7a3f31d8091eb4e03141fd75c83e))
6
+ - add thread event listner ([904b78d](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/904b78dd3271552d2167d467814aa4add1550201))
7
+ - add useSubscribeThreadClosed event listener ([edfa1ee](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/edfa1ee8cd7296292ad485d283f3675d6a626ac1))
8
+ - add useSubscribeThreadClosed to container.tsx ([53aa939](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/53aa939561a1ad3415ba7735344632390015764b))
9
+
10
+ ## [1.8.2](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.8.1...v1.8.2) (2025-07-16)
11
+
1
12
  ## [1.8.1](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.8.0...v1.8.1) (2025-07-15)
2
13
 
3
14
  # [1.8.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.7.0...v1.8.0) (2025-07-14)
@@ -11,6 +11,7 @@ require('dotenv').config({
11
11
  })
12
12
 
13
13
  module.exports = async function (env) {
14
+ const standaloneMode = env?.local
14
15
  const productionMode = env?.production || env?.staging
15
16
  const releaseFullName = `app-tutor-ai-consumer_v${packageJSON.version}`
16
17
  const fileVersion = `${productionMode ? 'prod' : 'dev'}-${packageJSON?.version}`
@@ -23,7 +24,7 @@ module.exports = async function (env) {
23
24
  */
24
25
  const config = {
25
26
  mode: productionMode ? 'production' : 'development',
26
- entry: productionMode ? paths.INDEX : paths.DEV_BOOTSTRAP,
27
+ entry: standaloneMode ? paths.DEV_BOOTSTRAP : paths.INDEX,
27
28
  ...(productionMode ? {} : require('./utils/devserver.config')),
28
29
  optimization: {
29
30
  usedExports: true,
@@ -159,17 +160,14 @@ module.exports = async function (env) {
159
160
  new rspack.DefinePlugin({
160
161
  'process.env.PROJECT_VERSION': JSON.stringify(packageJSON.version),
161
162
  'process.env.TARGET_ENV': JSON.stringify(process.env.NODE_ENV),
162
- 'process.env.RELEASE_FULL_NAME': JSON.stringify(releaseFullName)
163
+ 'process.env.RELEASE_FULL_NAME': JSON.stringify(releaseFullName),
164
+ 'process.env.STANDALONE_MODE': JSON.stringify(standaloneMode)
163
165
  }),
164
166
  new rspack.ProgressPlugin(),
165
167
  new TsCheckerRspackPlugin(),
166
168
  new rspack.HtmlRspackPlugin({
167
169
  template: path.resolve(paths.PUBLIC, 'index.html'),
168
170
  favicon: path.resolve(paths.PUBLIC, 'favicon.ico')
169
- }),
170
- new rspack.CssExtractRspackPlugin({
171
- filename: productionMode ? `app-tutor-ai-consumer.css` : '[name].[contenthash].css',
172
- chunkFilename: productionMode ? '[id].[contenthash].css' : '[id].[contenthash].css'
173
171
  })
174
172
  ].concat(await getEnvironmentPlugins(!productionMode)),
175
173
  watchOptions: {
@@ -27,8 +27,8 @@ async function getEnvironmentPlugins(isDevelopment) {
27
27
  return [
28
28
  new rspack.EnvironmentPlugin(allEnvs),
29
29
  new rspack.CssExtractRspackPlugin({
30
- filename: '[name].[contenthash].css',
31
- chunkFilename: '[id]-app-tutor-ai-consumer.css'
30
+ filename: 'app-tutor-ai-consumer.css',
31
+ chunkFilename: '[id].[contenthash].css'
32
32
  }),
33
33
  new CompressionPlugin({
34
34
  algorithm: 'gzip'
@@ -1,6 +1,9 @@
1
1
  import SparkieMock from '@/src/modules/sparkie/__tests__/sparkie.mock'
2
2
  import { SparkieService } from '@/src/modules/sparkie'
3
- import { SparkieMessageServiceMock } from '@/src/modules/sparkie/__tests__/sparkie.mock'
3
+ import {
4
+ SparkieMessageServiceMock,
5
+ SparkieCursorServiceMock
6
+ } from '@/src/modules/sparkie/__tests__/sparkie.mock'
4
7
  import MessageService from '@hotmart/sparkie/dist/MessageService'
5
8
 
6
9
  vi.mock('@hotmart/sparkie', () => ({ default: SparkieMock }))
@@ -9,4 +12,6 @@ beforeEach(() => {
9
12
  vi.spyOn(SparkieService, 'getMessageService').mockResolvedValue(
10
13
  SparkieMessageServiceMock as unknown as MessageService
11
14
  )
15
+
16
+ vi.spyOn(SparkieService, 'getCursorService').mockResolvedValue(SparkieCursorServiceMock as never)
12
17
  })
@@ -14,7 +14,6 @@ export default defineConfig({
14
14
  './config/vitest/__mocks__/sparkie.tsx',
15
15
  './config/vitest/__mocks__/icons.tsx',
16
16
  './config/vitest/__mocks__/intersection-observer.ts',
17
- './config/vitest/__mocks__/use-init-sparkie.tsx',
18
17
  './config/vitest/polyfills/global.js'
19
18
  ],
20
19
  coverage: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-tutor-ai-consumer",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "dev": "rspack serve --env=development --config config/rspack/rspack.config.js",
@@ -5,10 +5,25 @@ import { v4 } from 'uuid'
5
5
  import { LANGUAGES } from './config/i18n'
6
6
  import { devMode } from './lib/utils'
7
7
 
8
+ const rootId = 'app-tutor-ai-widget'
9
+
8
10
  if (devMode) {
9
11
  window.TOKEN = process.env.TOKEN ?? ''
10
12
  void (async () => {
11
- await window.startChatWidget('c3po-app-widget', {
13
+ // TODO: Remove after sidebar implementation
14
+ // Add Local popup css config
15
+ const root = document.getElementById('c3po-app-widget')
16
+ const container = document.createElement('div')
17
+
18
+ container.setAttribute('id', rootId)
19
+ container.setAttribute(
20
+ 'class',
21
+ 'bg-ai-dark fixed bottom-5 right-5 w-[27rem] h-[min(43rem,calc(100vh-2.5rem))] max-h-[calc(100vh-2.5rem)] z-10 rounded-[0.625rem] border border-neutral-800 shadow-lg overflow-hidden flex flex-col'
22
+ )
23
+
24
+ root?.appendChild(container)
25
+
26
+ await window.startChatWidget(rootId, {
12
27
  hotmartToken: window.TOKEN,
13
28
  locale: LANGUAGES.PT_BR,
14
29
  conversationId: '21506473-a93c-4b38-9c32-68a5ca37ce73', // OWNER
package/src/index.tsx CHANGED
@@ -7,10 +7,10 @@ import { createRoot } from 'react-dom/client'
7
7
  import { initDayjs } from './config/dayjs'
8
8
  import { initLanguage } from './config/i18n'
9
9
  import { initAxios } from './config/request/api'
10
- import { productionMode } from './lib/utils'
10
+ import { devMode, productionMode } from './lib/utils'
11
11
  import { Main } from './main'
12
12
  import { SparkieService } from './modules/sparkie'
13
- import { TutorWidgetEvents, TutorWidgetEventTypes } from './modules/widget'
13
+ import { TutorWidgetEvents } from './modules/widget'
14
14
  import type { WidgetSettingProps } from './types'
15
15
 
16
16
  const loadMainStyles = () => {
@@ -33,30 +33,49 @@ window.startChatWidget = async (
33
33
  elementId = 'tutor-chat-app-widget',
34
34
  settings: WidgetSettingProps
35
35
  ) => {
36
- loadMainStyles()
36
+ if (!devMode) {
37
+ loadMainStyles()
38
+ }
37
39
 
38
40
  const rootElement = document.getElementById(elementId) as HTMLElement
39
41
  const root = createRoot(rootElement)
40
42
 
41
- await SparkieService.initSparkie({
42
- token: settings.hotmartToken,
43
- skipPresenceSetup: true,
44
- retryOptions: {
45
- maxRetries: 5,
46
- retryDelay: 2000,
47
- backoffMultiplier: 1.5
48
- }
49
- })
50
43
  initAxios(settings.hotmartToken)
51
44
  await initLanguage(settings.locale)
52
45
  await initDayjs(settings.locale)
53
46
 
54
- if (root)
47
+ let isLoadingSparkie: boolean = false
48
+ let initSparkieError: unknown = null
49
+
50
+ try {
51
+ isLoadingSparkie = true
52
+ await SparkieService.initSparkie({
53
+ token: settings?.hotmartToken,
54
+ skipPresenceSetup: true,
55
+ retryOptions: {
56
+ maxRetries: 5,
57
+ retryDelay: 2000,
58
+ backoffMultiplier: 1.5
59
+ }
60
+ })
61
+ await SparkieService.ensureInitialized()
62
+ TutorWidgetEvents['tutor-app-widget-loaded'].dispatch()
63
+ } catch (error) {
64
+ initSparkieError = error
65
+ console.error(error)
66
+ TutorWidgetEvents['tutor-app-widget-loaded'].dispatch({ detail: { isSuccess: false } })
67
+ } finally {
68
+ isLoadingSparkie = false
69
+ }
70
+
71
+ if (root) {
72
+ TutorWidgetEvents['c3po-app-widget-open'].dispatch()
55
73
  root.render(
56
74
  <StrictMode>
57
- <Main settings={settings} />
75
+ <Main settings={settings} metadata={{ initSparkieError, isLoadingSparkie }} />
58
76
  </StrictMode>
59
77
  )
78
+ }
60
79
  }
61
80
 
62
- window.closeChatWidget = () => TutorWidgetEvents.get(TutorWidgetEventTypes.CLOSE)?.dispatch()
81
+ window.closeChatWidget = () => TutorWidgetEvents['c3po-app-widget-close'].dispatch()
@@ -0,0 +1 @@
1
+ export * from './shared-ref'
@@ -0,0 +1 @@
1
+ export { default as createSharedRefContext } from './shared-ref'
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext, useRef } from 'react'
2
+ import type { PropsWithChildren, RefObject } from 'react'
3
+
4
+ function createSharedRefContext<T extends HTMLElement>() {
5
+ const SharedRefContext = createContext<RefObject<T | null> | null>(null)
6
+
7
+ const SharedRefContextProvider = ({ children }: PropsWithChildren) => {
8
+ const sharedRef = useRef<T>(null)
9
+ return <SharedRefContext.Provider value={sharedRef}>{children}</SharedRefContext.Provider>
10
+ }
11
+
12
+ const useSharedRefContext = () => {
13
+ const ctx = useContext(SharedRefContext)
14
+
15
+ if (!ctx) throw new Error('SharedRefContext must be used inside a SharedRefContextProvider')
16
+ return ctx
17
+ }
18
+
19
+ return {
20
+ SharedRefContext,
21
+ SharedRefContextProvider,
22
+ useSharedRefContext
23
+ }
24
+ }
25
+
26
+ export default createSharedRefContext
@@ -1,9 +1,12 @@
1
1
  import { chance, render, screen, waitFor } from '@/config/tests'
2
+ import { useWidgetSettingsAtom } from '@/src/modules/widget/store/widget-settings.atom'
2
3
  import WidgetSettingPropsBuilder from '../modules/widget/__tests__/widget-settings-props.builder'
3
- import { useWidgetSettingsAtom } from '../modules/widget/store/widget-settings.atom'
4
4
  import { Main } from '.'
5
5
 
6
- vi.mock('../modules/widget/store/widget-settings.atom', () => ({ useWidgetSettingsAtom: vi.fn() }))
6
+ vi.mock('@/src/modules/widget/store/widget-settings.atom', async (importOriginal) => ({
7
+ ...(await importOriginal()),
8
+ useWidgetSettingsAtom: vi.fn()
9
+ }))
7
10
 
8
11
  describe('Main', () => {
9
12
  const defaultProps = new WidgetSettingPropsBuilder()
@@ -20,7 +23,7 @@ describe('Main', () => {
20
23
  renderComponent({ settings: props })
21
24
 
22
25
  await waitFor(() => {
23
- expect(screen.getByText(/onboarding.description/i)).toBeInTheDocument()
26
+ expect(screen.getByText(/send/i)).toBeInTheDocument()
24
27
  })
25
28
  })
26
29
  })
package/src/main/main.tsx CHANGED
@@ -3,13 +3,31 @@ import '@/config/styles/index.css'
3
3
  import { ErrorBoundary, GenericError } from '@/src/lib/components/errors'
4
4
  import { useDefaultId } from '@/src/lib/hooks'
5
5
  import { useAppLang } from '../config/i18n'
6
+ import { Spinner } from '../lib/components'
6
7
  import { GlobalProviders } from '../modules/global-providers'
7
8
  import { WidgetContainer } from '../modules/widget'
9
+ import { useListenToVisibilityEvents } from '../modules/widget/hooks'
8
10
  import type { WidgetSettingProps } from '../types'
9
11
 
10
- function Main({ settings }: { settings: WidgetSettingProps }) {
12
+ export type MainProps = {
13
+ settings: WidgetSettingProps
14
+ metadata?: { isLoadingSparkie: boolean; initSparkieError: unknown }
15
+ }
16
+ function Main({
17
+ settings,
18
+ metadata = { initSparkieError: null, isLoadingSparkie: false }
19
+ }: MainProps) {
11
20
  useDefaultId()
12
21
  useAppLang(settings.locale)
22
+ useListenToVisibilityEvents()
23
+
24
+ if (metadata.isLoadingSparkie) {
25
+ return (
26
+ <div className='flex h-full w-full flex-col items-center justify-center'>
27
+ <Spinner className='h-10 w-10 text-neutral-500' />
28
+ </div>
29
+ )
30
+ }
13
31
 
14
32
  return (
15
33
  <ErrorBoundary fallback={<GenericError />}>
@@ -1,12 +1,30 @@
1
+ import { ApiError } from '@/src/config/request'
2
+ import { HttpCodes } from '@/src/lib/utils'
1
3
  import { SparkieService } from '../sparkie'
2
4
 
3
5
  import type { ICursorUpdate } from './types'
4
6
 
5
7
  class CursorService {
6
- constructor(private sparkie = SparkieService.sparkieInstance) {}
8
+ async getSparkieCursorService() {
9
+ try {
10
+ const messageService = await SparkieService.getCursorService()
11
+
12
+ if (!messageService) throw new Error()
13
+
14
+ return messageService
15
+ } catch (error) {
16
+ throw new ApiError({
17
+ statusCode: HttpCodes.UNPROCESSABLE_ENTITY,
18
+ message: 'sparkie.cursorService not defined',
19
+ extra: { error }
20
+ })
21
+ }
22
+ }
7
23
 
8
24
  async updateCursor(conversationId: string): Promise<ICursorUpdate | null> {
9
- const data = await this.sparkie.cursorService?.update(conversationId)
25
+ const cursorService = await this.getSparkieCursorService()
26
+
27
+ const data = await cursorService?.update(conversationId)
10
28
 
11
29
  return data ?? null
12
30
  }
@@ -1,4 +1,5 @@
1
1
  import { type PropsWithChildren, useEffect } from 'react'
2
+ import { v4 } from 'uuid'
2
3
 
3
4
  import { OptimizelyProvider } from '@/src/config/optimizely'
4
5
  import { QueryProvider } from '@/src/config/tanstack'
@@ -13,6 +14,10 @@ function GlobalProviders({ children, settings }: GlobalProvidersProps) {
13
14
  useEffect(() => {
14
15
  if (!settings || !Object.keys(settings)?.length) return
15
16
 
17
+ if (!settings?.sessionId) {
18
+ settings.sessionId = v4()
19
+ }
20
+
16
21
  setWidgetSettings(settings)
17
22
  }, [setWidgetSettings, settings])
18
23
 
@@ -1,7 +1,8 @@
1
1
  import { lazy, useCallback, useRef } from 'react'
2
2
  import clsx from 'clsx'
3
+ import { createPortal } from 'react-dom'
3
4
 
4
- import { useWidgetLoadingAtomValue } from '@/src/modules/widget'
5
+ import { usePageLayoutMainRefContext, useWidgetLoadingAtomValue } from '@/src/modules/widget'
5
6
  import { useAllMessages, useManageScroll } from '../../hooks'
6
7
  import { useSkeletonRef } from '../../hooks/use-skeleton-ref'
7
8
  import { MessageItem } from '../message-item'
@@ -26,6 +27,7 @@ function MessagesList() {
26
27
  const widgetIsLoading = useWidgetLoadingAtomValue()
27
28
  const skeletonRef = useSkeletonRef()
28
29
  const { showScrollButton } = useManageScroll(scrollerRef)
30
+ const mainLayoutRef = usePageLayoutMainRefContext()
29
31
 
30
32
  const scrollToBottom = useCallback(() => {
31
33
  const { current: scroller } = scrollerRef
@@ -39,23 +41,21 @@ function MessagesList() {
39
41
  }, [])
40
42
 
41
43
  return (
42
- <div ref={scrollerRef} className='relative mx-2 my-4 flex flex-col gap-2 overflow-auto px-4'>
44
+ <div ref={scrollerRef} className='mx-2 my-4 flex flex-col gap-2 overflow-auto px-4'>
43
45
  <MessageItemLoading show={messagesQuery.isFetching} />
44
46
 
45
47
  <MessageItemEndOfScroll
46
48
  show={!messagesQuery.isFetching && !messagesQuery.hasNextPage && allMessages.length > 0}
47
49
  />
48
-
49
- <ScrollToBottomButton
50
- ref={scrollToButtonRef}
51
- top={Math.abs(
52
- Number(scrollerRef.current?.clientHeight) -
53
- Number(scrollToButtonRef.current?.clientHeight) -
54
- 24
50
+ {mainLayoutRef.current &&
51
+ createPortal(
52
+ <ScrollToBottomButton
53
+ ref={scrollToButtonRef}
54
+ show={showScrollButton}
55
+ onClick={scrollToBottom}
56
+ />,
57
+ mainLayoutRef.current
55
58
  )}
56
- show={showScrollButton}
57
- onClick={scrollToBottom}
58
- />
59
59
 
60
60
  {allMessages?.map(([publishingDate, messages], i) => (
61
61
  <div key={i} className='flex flex-1 flex-col justify-center gap-6'>
@@ -0,0 +1,25 @@
1
+ import type { ICustomEvent } from '@/src/types'
2
+
3
+ import type { SubmitQuestionEventDetail } from './types'
4
+
5
+ export const MessagesEventTypes = {
6
+ SUBMIT_QUESTION: 'c3po-chat:questionSubmitted'
7
+ } as const
8
+
9
+ const MessagesEventsList: Array<ICustomEvent<typeof MessagesEventTypes>> = [
10
+ {
11
+ name: MessagesEventTypes.SUBMIT_QUESTION,
12
+ handler: () => () => undefined,
13
+ dispatch: () => {
14
+ const event: CustomEventInit<SubmitQuestionEventDetail> = {
15
+ detail: {
16
+ timestamp: Date.now()
17
+ }
18
+ }
19
+
20
+ window.dispatchEvent(new CustomEvent(MessagesEventTypes.SUBMIT_QUESTION, event))
21
+ }
22
+ }
23
+ ] as const
24
+
25
+ export const MessagesEvents = new Map(MessagesEventsList.map((e) => [e.name, e]))
@@ -3,6 +3,7 @@ import { MessagesService } from '@/src/modules/messages'
3
3
  import { useGetProfile } from '@/src/modules/profile'
4
4
  import * as Store from '@/src/modules/widget'
5
5
  import WidgetSettingPropsBuilder from '@/src/modules/widget/__tests__/widget-settings-props.builder'
6
+ import { MessagesEventTypes } from '../../events'
6
7
 
7
8
  import useSendTextMessage from './use-send-text-message'
8
9
 
@@ -83,4 +84,24 @@ describe('useSendTextMessage', () => {
83
84
  }
84
85
  })
85
86
  })
87
+
88
+ it('should dispatch window custom event when mutating', async () => {
89
+ const num = chance.integer()
90
+ vi.spyOn(globalThis.window, 'dispatchEvent')
91
+ vi.spyOn(Date, 'now').mockReturnValueOnce(num)
92
+
93
+ const { result } = render()
94
+
95
+ await waitFor(() => result.current.mutateAsync(message))
96
+
97
+ expect(globalThis.window.dispatchEvent).toHaveBeenCalledTimes(1)
98
+ expect(globalThis.window.dispatchEvent).toHaveBeenNthCalledWith(
99
+ 1,
100
+ new CustomEvent(MessagesEventTypes.SUBMIT_QUESTION, {
101
+ detail: {
102
+ timestamp: num
103
+ }
104
+ })
105
+ )
106
+ })
86
107
  })
@@ -4,6 +4,7 @@ import { v4 } from 'uuid'
4
4
  import { MessagesService } from '@/src/modules/messages'
5
5
  import { useGetProfile } from '@/src/modules/profile'
6
6
  import { useWidgetLoadingAtom, useWidgetSettingsAtomValue } from '@/src/modules/widget'
7
+ import { MessagesEvents } from '../../events'
7
8
 
8
9
  function useSendTextMessage() {
9
10
  const settings = useWidgetSettingsAtomValue()
@@ -53,6 +54,7 @@ function useSendTextMessage() {
53
54
  },
54
55
  onMutate: () => {
55
56
  setWidgetLoading(true)
57
+ MessagesEvents.get('c3po-chat:questionSubmitted')?.dispatch()
56
58
  }
57
59
  })
58
60
  }
@@ -3,9 +3,11 @@ import type { InfiniteData } from '@tanstack/react-query'
3
3
  import { useQueryClient } from '@tanstack/react-query'
4
4
  import { produce } from 'immer'
5
5
 
6
+ import { useUpdateCursor } from '@/src/modules/cursor/hooks'
6
7
  import { useGetProfile } from '@/src/modules/profile'
7
8
  import { SparkieService } from '@/src/modules/sparkie'
8
9
  import { useWidgetLoadingAtom, useWidgetSettingsAtom } from '@/src/modules/widget'
10
+ import { useUnreadMessagesSetAtom } from '../../store'
9
11
  import type { FetchMessagesResponse, IMessageWithSenderData } from '../../types'
10
12
  import { getMessagesInfiniteQuery } from '../use-infinite-get-messages'
11
13
 
@@ -14,6 +16,8 @@ const useSubscribeMessageReceivedEvent = () => {
14
16
  const profileQuery = useGetProfile()
15
17
  const queryClient = useQueryClient()
16
18
  const [, setWidgetLoading] = useWidgetLoadingAtom()
19
+ const [, addUnreadMessagesToSet] = useUnreadMessagesSetAtom()
20
+ const useUpdateCursorMutation = useUpdateCursor()
17
21
 
18
22
  const conversationId = useMemo(() => String(settings?.conversationId), [settings?.conversationId])
19
23
  const profileId = useMemo(() => String(profileQuery?.data?.id), [profileQuery?.data?.id])
@@ -61,7 +65,11 @@ const useSubscribeMessageReceivedEvent = () => {
61
65
  const isMine = data.contactId === profileId
62
66
 
63
67
  if (!isMine) {
68
+ addUnreadMessagesToSet({ itemId: data.id })
64
69
  setTimeout(() => setWidgetLoading(false), 100)
70
+ } else {
71
+ // The cursor should update only with my messages
72
+ useUpdateCursorMutation.mutate(data.conversationId)
65
73
  }
66
74
  }
67
75
 
@@ -74,7 +82,14 @@ const useSubscribeMessageReceivedEvent = () => {
74
82
  messageReceived
75
83
  })
76
84
  }
77
- }, [profileId, query.queryKey, queryClient, setWidgetLoading])
85
+ }, [
86
+ addUnreadMessagesToSet,
87
+ profileId,
88
+ query.queryKey,
89
+ queryClient,
90
+ setWidgetLoading,
91
+ useUpdateCursorMutation
92
+ ])
78
93
  }
79
94
 
80
95
  export default useSubscribeMessageReceivedEvent
@@ -0,0 +1 @@
1
+ export * from './unread-messages-set.atom'
@@ -0,0 +1,21 @@
1
+ import { atom, useAtom, useAtomValue } from 'jotai'
2
+
3
+ const unreadMessagesSetAtom = atom(new Set<string>())
4
+
5
+ const setUnreadMessagesSetAtom = atom(
6
+ (get) => get(unreadMessagesSetAtom),
7
+ (_, set, { itemId = '', clear = false }: { itemId?: string; clear?: boolean }) =>
8
+ set(unreadMessagesSetAtom, (p) => {
9
+ if (clear) return new Set<string>()
10
+
11
+ const previousItems = Array.from(p)
12
+
13
+ return new Set([...previousItems, itemId])
14
+ })
15
+ )
16
+
17
+ const unreadMessagesAtom = atom((get) => get(unreadMessagesSetAtom).size)
18
+
19
+ export const useUnreadMessagesSetAtom = () => useAtom(setUnreadMessagesSetAtom)
20
+ export const useUnreadMessagesSetAtomValue = () => useAtomValue(setUnreadMessagesSetAtom)
21
+ export const useUnreadMessagesCount = () => useAtomValue(unreadMessagesAtom)
@@ -76,3 +76,7 @@ export type FetchMessagesResponse = {
76
76
  messages: IMessageWithSenderData[]
77
77
  hasMore: boolean
78
78
  }
79
+
80
+ export type SubmitQuestionEventDetail = {
81
+ timestamp: number
82
+ }
@@ -153,7 +153,7 @@ class SparkieService {
153
153
  return false
154
154
  }
155
155
 
156
- private async ensureInitialized(): Promise<void> {
156
+ async ensureInitialized(): Promise<void> {
157
157
  if (this.isInitialized) return
158
158
 
159
159
  switch (this.initializationState) {
@@ -178,6 +178,16 @@ class SparkieService {
178
178
  return messageService
179
179
  }
180
180
 
181
+ async getCursorService() {
182
+ await this.ensureInitialized()
183
+
184
+ const cursorService = this.sparkieInstance.cursorService
185
+
186
+ if (!cursorService) throw new Error('CursorService not available after initialization')
187
+
188
+ return cursorService
189
+ }
190
+
181
191
  async updateToken(token: string, reinitialize = true): Promise<boolean> {
182
192
  this.sparkieInstance.setAPIToken(token)
183
193
 
@@ -225,11 +235,11 @@ class SparkieService {
225
235
 
226
236
  async destroySparkie(): Promise<void> {
227
237
  try {
228
- if (this.sparkie && this.initializationState !== 'idle') {
238
+ if (this.sparkie) {
229
239
  await this.sparkieInstance.destroy({ skipSignOut: true })
230
240
  }
231
- } catch (error) {
232
- console.error('Error destroying Sparkie:', error)
241
+ } catch {
242
+ console.warn('Sparkie instance not available for destruction')
233
243
  } finally {
234
244
  this.initializationState = 'idle'
235
245
  this.initializationPromise = null
@@ -0,0 +1 @@
1
+ export * from './use-subscribe-thread-closed-event'
@@ -0,0 +1,2 @@
1
+ export * from './use-subscribe-thread-closed-event'
2
+ export { default as useSubscribeThreadClosedEvent } from './use-subscribe-thread-closed-event'
@@ -0,0 +1,22 @@
1
+ import { useCallback, useEffect } from 'react'
2
+
3
+ import { useUnreadMessagesSetAtom } from '@/src/modules/messages/store'
4
+ import { SparkieService } from '@/src/modules/sparkie'
5
+
6
+ function useSubscribeThreadClosedEvent() {
7
+ const [, setUnreadMessagesSet] = useUnreadMessagesSetAtom()
8
+
9
+ const threadClosed = useCallback(() => {
10
+ setUnreadMessagesSet({ clear: true })
11
+ }, [setUnreadMessagesSet])
12
+
13
+ useEffect(() => {
14
+ SparkieService.subscribeEvents({ threadClosed })
15
+
16
+ return () => {
17
+ SparkieService.removeEventSubscription({ threadClosed })
18
+ }
19
+ }, [threadClosed])
20
+ }
21
+
22
+ export default useSubscribeThreadClosedEvent
@@ -0,0 +1 @@
1
+ export * from './hooks'
@@ -1,10 +1,12 @@
1
1
  import { useSubscribeMessageReceivedEvent } from '@/src/modules/messages/hooks'
2
+ import { useSubscribeThreadClosedEvent } from '@/src/modules/thread/hooks'
2
3
  import { useWidgetTabsValueAtom } from '../../store'
3
4
  import { WIDGET_TABS } from '../constants'
4
5
 
5
6
  function WidgetContainer() {
6
7
  const widgetTabs = useWidgetTabsValueAtom()
7
8
  useSubscribeMessageReceivedEvent()
9
+ useSubscribeThreadClosedEvent()
8
10
 
9
11
  return (
10
12
  <div className='flex h-full flex-col items-center justify-stretch overflow-hidden'>
@@ -0,0 +1,8 @@
1
+ import { createSharedRefContext } from '@/src/lib/contexts'
2
+
3
+ const { useSharedRefContext, SharedRefContext, SharedRefContextProvider } =
4
+ createSharedRefContext<HTMLDivElement>()
5
+
6
+ export const usePageLayoutMainRefContext = useSharedRefContext
7
+ export const PageLayoutMainRefContext = SharedRefContext
8
+ export const PageLayoutMainRefContextProvider = SharedRefContextProvider
@@ -1,2 +1,3 @@
1
+ export * from './constants'
1
2
  export * from './page-layout'
2
3
  export { default as PageLayout } from './page-layout'
@@ -1,18 +1,25 @@
1
+ import { type PropsWithChildren, type ReactNode } from 'react'
1
2
  import clsx from 'clsx'
2
- import type { PropsWithChildren, ReactNode } from 'react'
3
+
4
+ import { PageLayoutMainRefContextProvider, usePageLayoutMainRefContext } from './constants'
3
5
 
4
6
  export type PageLayoutProps = PropsWithChildren<{
5
7
  asideChild?: ReactNode
6
8
  className?: string
7
9
  }>
10
+
8
11
  function PageLayout({ asideChild, children, className }: PageLayoutProps) {
12
+ const mainLayoutRef = usePageLayoutMainRefContext()
13
+
9
14
  return (
10
15
  <div
11
16
  className={clsx(
12
17
  'grid-areas-[main_aside] grid h-full min-h-0 w-full grid-cols-1 grid-rows-[1fr_auto]',
13
18
  className
14
19
  )}>
15
- <div className='grid-area-[main] flex min-h-0 flex-col overflow-y-auto overflow-x-hidden'>
20
+ <div
21
+ ref={mainLayoutRef}
22
+ className='grid-area-[main] relative flex min-h-0 flex-col overflow-y-auto overflow-x-hidden'>
16
23
  {children}
17
24
  </div>
18
25
  {asideChild && (
@@ -24,4 +31,12 @@ function PageLayout({ asideChild, children, className }: PageLayoutProps) {
24
31
  )
25
32
  }
26
33
 
27
- export default PageLayout
34
+ function PageLayoutWrapper(props: PageLayoutProps) {
35
+ return (
36
+ <PageLayoutMainRefContextProvider>
37
+ <PageLayout {...props} />
38
+ </PageLayoutMainRefContextProvider>
39
+ )
40
+ }
41
+
42
+ export default PageLayoutWrapper
@@ -10,14 +10,13 @@ export interface IScrollToBottomButtonProps
10
10
  }
11
11
 
12
12
  const ScrollToBottomButton = forwardRef<HTMLButtonElement, IScrollToBottomButtonProps>(
13
- ({ show = false, top = 0, onClick, className, ...props }, ref) => (
13
+ ({ show = false, onClick, className, ...props }, ref) => (
14
14
  <button
15
15
  {...props}
16
- style={isNaN(Number(top)) ? undefined : { top }}
17
16
  ref={ref}
18
17
  className={clsx(
19
- 'fixed inset-x-1/2 flex size-7 cursor-pointer flex-col items-center justify-center rounded-full bg-neutral-600 text-sm text-neutral-50 outline-none transition-colors duration-300 ease-in hover:scale-110 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2',
20
- { 'pointer-events-none opacity-0': !show },
18
+ 'absolute bottom-4 left-1/2 flex size-7 cursor-pointer flex-col items-center justify-center rounded-full bg-neutral-600 text-sm text-neutral-50 outline-none transition-colors duration-300 ease-in hover:scale-110 hover:bg-neutral-700 focus:outline-none focus:ring-neutral-500 focus:ring-offset-2 focus-visible:ring-2 active:ring-2',
19
+ { 'opacity-85': show, 'pointer-events-none opacity-0': !show },
21
20
  className
22
21
  )}
23
22
  onClick={onClick}
@@ -1,24 +1,65 @@
1
1
  import type { ITutorWidgetEvent } from './types'
2
2
 
3
3
  export const TutorWidgetEventTypes = {
4
- OPEN: 'tutor-app-widget-open',
5
- CLOSE: 'tutor-app-widget-close'
4
+ OPEN: 'c3po-app-widget-open',
5
+ CLOSE: 'c3po-app-widget-close',
6
+ LOADED: 'tutor-app-widget-loaded'
6
7
  } as const
7
8
 
8
- const TutorWidgetEventsList: Array<ITutorWidgetEvent> = [
9
- {
9
+ const TutorWidgetEventsObject = {
10
+ [TutorWidgetEventTypes.OPEN]: {
10
11
  name: TutorWidgetEventTypes.OPEN,
11
- handler: () => () => undefined,
12
- dispatch: () => window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.OPEN))
13
- },
14
- {
12
+ handler: (callback) => {
13
+ const listener: EventListener = () => {
14
+ void callback()
15
+ }
16
+
17
+ window.addEventListener(TutorWidgetEventTypes.OPEN, listener)
18
+
19
+ return () => {
20
+ window.removeEventListener(TutorWidgetEventTypes.OPEN, listener)
21
+ }
22
+ },
23
+ dispatch: () => {
24
+ window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.OPEN))
25
+ }
26
+ } as ITutorWidgetEvent<void>,
27
+
28
+ [TutorWidgetEventTypes.CLOSE]: {
15
29
  name: TutorWidgetEventTypes.CLOSE,
16
- handler: () => () => undefined,
30
+ handler: (callback) => {
31
+ const listener: EventListener = () => {
32
+ void callback()
33
+ }
34
+
35
+ window.addEventListener(TutorWidgetEventTypes.CLOSE, listener)
36
+
37
+ return () => {
38
+ window.removeEventListener(TutorWidgetEventTypes.CLOSE, listener)
39
+ }
40
+ },
17
41
  dispatch: () => window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.CLOSE))
18
- }
19
- ] as const
42
+ } as ITutorWidgetEvent<void>,
43
+
44
+ [TutorWidgetEventTypes.LOADED]: {
45
+ name: TutorWidgetEventTypes.LOADED,
46
+ handler: (callback: (payload: { isSuccess: boolean }) => void) => {
47
+ const listener: EventListener = (e) => {
48
+ const evt = e as CustomEvent<{ isSuccess: boolean }>
49
+ callback(evt.detail)
50
+ }
51
+ window.addEventListener(TutorWidgetEventTypes.LOADED, listener)
52
+ return () => {
53
+ window.removeEventListener(TutorWidgetEventTypes.LOADED, listener)
54
+ }
55
+ },
56
+ dispatch: (payload = { detail: { isSuccess: true } }) => {
57
+ window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.LOADED, payload))
58
+ }
59
+ } as ITutorWidgetEvent<{ isSuccess: boolean }>
60
+ } as const
20
61
 
21
- export const TutorWidgetEvents = new Map(TutorWidgetEventsList.map((e) => [e.name, e]))
62
+ export const TutorWidgetEvents = TutorWidgetEventsObject
22
63
 
23
64
  export const ACTION_EVENTS = {
24
65
  SCROLL: 'c3po-app-widget-scroll-to-bottom'
@@ -1 +1 @@
1
- export * from './use-init-sparkie'
1
+ export * from './use-listen-to-visibility-events'
@@ -0,0 +1 @@
1
+ export { default as useListenToVisibilityEvents } from './use-listen-to-visibility-events'
@@ -0,0 +1,20 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { SparkieService } from '@/src/modules/sparkie'
4
+ import { TutorWidgetEvents } from '../../events'
5
+
6
+ function useListenToVisibilityEvents() {
7
+ useEffect(() => {
8
+ const listener = async () => {
9
+ await SparkieService.destroySparkie()
10
+ }
11
+
12
+ const clear = TutorWidgetEvents['c3po-app-widget-close'].handler(listener)
13
+
14
+ return () => {
15
+ clear?.()
16
+ }
17
+ }, [])
18
+ }
19
+
20
+ export default useListenToVisibilityEvents
@@ -8,8 +8,8 @@ export type WidgetTabsProps = {
8
8
  }
9
9
 
10
10
  const INITIAL_PROPS: WidgetTabsProps = {
11
- currentTab: 'onboarding',
12
- history: new Set(['onboarding'])
11
+ currentTab: 'starter',
12
+ history: new Set(['starter'])
13
13
  }
14
14
 
15
15
  export const widgetTabsAtom = atom<WidgetTabsProps>(INITIAL_PROPS)
@@ -1,7 +1,7 @@
1
1
  import type { TutorWidgetEventTypes } from './events'
2
2
 
3
- export type ITutorWidgetEvent = {
3
+ export type ITutorWidgetEvent<T = unknown> = {
4
4
  name: (typeof TutorWidgetEventTypes)[keyof typeof TutorWidgetEventTypes]
5
- handler: (listener: EventListenerOrEventListenerObject) => () => void
6
- dispatch: <T = unknown>(payload?: CustomEventInit<T>) => void
5
+ handler: (callback: (payload: T) => void | Promise<void>) => () => void
6
+ dispatch: (payload?: CustomEventInit<T>) => void
7
7
  }
package/src/types.ts CHANGED
@@ -36,3 +36,9 @@ export type WidgetSettingProps = {
36
36
  owner_id?: string
37
37
  current_media_codes?: string
38
38
  }
39
+
40
+ export interface ICustomEvent<T = object> {
41
+ name: T[keyof T]
42
+ handler: (listener: EventListenerOrEventListenerObject) => () => void | Promise<void>
43
+ dispatch: <D = unknown>(detail?: D) => void
44
+ }
@@ -1,14 +0,0 @@
1
- import { useInitSparkie } from '@/src/modules/widget/hooks/use-init-sparkie'
2
-
3
- vi.mock('@/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie', () => ({
4
- useInitSparkie: vi.fn()
5
- }))
6
-
7
- beforeEach(() => {
8
- vi.mocked(useInitSparkie).mockReturnValue({
9
- data: true,
10
- isError: false,
11
- isLoading: false,
12
- refetch: vi.fn()
13
- } as unknown as ReturnType<typeof useInitSparkie>)
14
- })
@@ -1 +0,0 @@
1
- export * from './use-init-sparkie'
@@ -1,20 +0,0 @@
1
- import { useQuery } from '@tanstack/react-query'
2
-
3
- import { SparkieService } from '@/src/modules/sparkie'
4
- import type { WidgetSettingProps } from '@/src/types'
5
-
6
- export const getInitSparkieQuery = (settings: WidgetSettingProps) => ({
7
- queryKey: ['SparkieService:initializeSparkie', settings?.hotmartToken ?? ''],
8
- queryFn: () =>
9
- SparkieService.initSparkie({
10
- token: settings?.hotmartToken,
11
- skipPresenceSetup: true
12
- })
13
- })
14
-
15
- export function useInitSparkie(settings: WidgetSettingProps | null) {
16
- return useQuery({
17
- ...getInitSparkieQuery(settings as WidgetSettingProps),
18
- enabled: Boolean(settings?.hotmartToken?.trim())
19
- })
20
- }