app-tutor-ai-consumer 1.8.2 → 1.10.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.
- package/CHANGELOG.md +15 -0
- package/config/vitest/__mocks__/sparkie.tsx +6 -1
- package/config/vitest/vitest.config.mts +0 -1
- package/package.json +1 -1
- package/src/index.tsx +4 -25
- package/src/main/main.spec.tsx +1 -1
- package/src/main/main.tsx +8 -2
- package/src/modules/cursor/service.ts +20 -2
- package/src/modules/messages/components/chat-input/chat-input.tsx +9 -3
- package/src/modules/messages/events.ts +25 -0
- package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +21 -0
- package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +6 -1
- package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +16 -1
- package/src/modules/messages/store/index.ts +1 -0
- package/src/modules/messages/store/unread-messages-set.atom.ts +21 -0
- package/src/modules/messages/types.ts +4 -0
- package/src/modules/sparkie/service.ts +13 -3
- package/src/modules/thread/hooks/index.ts +1 -0
- package/src/modules/thread/hooks/use-subscribe-thread-closed-event/index.ts +2 -0
- package/src/modules/thread/hooks/use-subscribe-thread-closed-event/use-subscribe-thread-closed-event.tsx +22 -0
- package/src/modules/thread/index.ts +1 -0
- package/src/modules/widget/components/constants.tsx +3 -1
- package/src/modules/widget/components/container/container.tsx +16 -3
- package/src/modules/widget/components/index.ts +1 -0
- package/src/modules/widget/components/loading-page/index.ts +1 -0
- package/src/modules/widget/components/loading-page/loading-page.tsx +41 -0
- package/src/modules/widget/events.ts +34 -18
- package/src/modules/widget/hooks/index.ts +2 -1
- package/src/modules/widget/hooks/use-init-widget/index.ts +1 -0
- package/src/modules/widget/hooks/use-init-widget/use-init-widget.tsx +47 -0
- package/src/modules/widget/hooks/use-listen-to-visibility-events/index.ts +1 -0
- package/src/modules/widget/hooks/use-listen-to-visibility-events/use-listen-to-visibility-events.tsx +20 -0
- package/src/modules/widget/store/widget-tabs.atom.ts +2 -2
- package/src/modules/widget/types.ts +1 -1
- package/src/types.ts +6 -0
- package/config/vitest/__mocks__/use-init-sparkie.tsx +0 -14
- package/src/modules/widget/hooks/use-init-sparkie/index.ts +0 -1
- package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +0 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# [1.10.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.9.0...v1.10.0) (2025-07-17)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- add new loading logic ([ddfcfb6](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/ddfcfb6b5018440a6cd4b5fb2a03d53ee949add7))
|
|
6
|
+
|
|
7
|
+
# [1.9.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.8.2...v1.9.0) (2025-07-17)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- add on close listener ([c41e700](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/c41e700241ac7a3f31d8091eb4e03141fd75c83e))
|
|
12
|
+
- add thread event listner ([904b78d](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/904b78dd3271552d2167d467814aa4add1550201))
|
|
13
|
+
- add useSubscribeThreadClosed event listener ([edfa1ee](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/edfa1ee8cd7296292ad485d283f3675d6a626ac1))
|
|
14
|
+
- add useSubscribeThreadClosed to container.tsx ([53aa939](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/53aa939561a1ad3415ba7735344632390015764b))
|
|
15
|
+
|
|
1
16
|
## [1.8.2](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.8.1...v1.8.2) (2025-07-16)
|
|
2
17
|
|
|
3
18
|
## [1.8.1](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.8.0...v1.8.1) (2025-07-15)
|
|
@@ -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 {
|
|
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
package/src/index.tsx
CHANGED
|
@@ -4,13 +4,10 @@ import './config/styles/index.css'
|
|
|
4
4
|
import { StrictMode } from 'react'
|
|
5
5
|
import { createRoot } from 'react-dom/client'
|
|
6
6
|
|
|
7
|
-
import { initDayjs } from './config/dayjs'
|
|
8
7
|
import { initLanguage } from './config/i18n'
|
|
9
|
-
import { initAxios } from './config/request/api'
|
|
10
8
|
import { devMode, productionMode } from './lib/utils'
|
|
11
9
|
import { Main } from './main'
|
|
12
|
-
import {
|
|
13
|
-
import { TutorWidgetEvents, TutorWidgetEventTypes } from './modules/widget'
|
|
10
|
+
import { TutorWidgetEvents } from './modules/widget'
|
|
14
11
|
import type { WidgetSettingProps } from './types'
|
|
15
12
|
|
|
16
13
|
const loadMainStyles = () => {
|
|
@@ -40,33 +37,15 @@ window.startChatWidget = async (
|
|
|
40
37
|
const rootElement = document.getElementById(elementId) as HTMLElement
|
|
41
38
|
const root = createRoot(rootElement)
|
|
42
39
|
|
|
43
|
-
initAxios(settings.hotmartToken)
|
|
44
40
|
await initLanguage(settings.locale)
|
|
45
|
-
await initDayjs(settings.locale)
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
await SparkieService.initSparkie({
|
|
49
|
-
token: settings?.hotmartToken,
|
|
50
|
-
skipPresenceSetup: true,
|
|
51
|
-
retryOptions: {
|
|
52
|
-
maxRetries: 5,
|
|
53
|
-
retryDelay: 2000,
|
|
54
|
-
backoffMultiplier: 1.5
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
await SparkieService.ensureInitialized()
|
|
58
|
-
TutorWidgetEvents.get(TutorWidgetEventTypes.LOADED)?.dispatch()
|
|
59
|
-
} catch (error) {
|
|
60
|
-
console.error(error)
|
|
61
|
-
TutorWidgetEvents.get(TutorWidgetEventTypes.LOADED)?.dispatch({ detail: { isSuccess: false } })
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (root)
|
|
42
|
+
if (root) {
|
|
65
43
|
root.render(
|
|
66
44
|
<StrictMode>
|
|
67
45
|
<Main settings={settings} />
|
|
68
46
|
</StrictMode>
|
|
69
47
|
)
|
|
48
|
+
}
|
|
70
49
|
}
|
|
71
50
|
|
|
72
|
-
window.closeChatWidget = () => TutorWidgetEvents.
|
|
51
|
+
window.closeChatWidget = () => TutorWidgetEvents['c3po-app-widget-close'].dispatch()
|
package/src/main/main.spec.tsx
CHANGED
package/src/main/main.tsx
CHANGED
|
@@ -5,16 +5,22 @@ import { useDefaultId } from '@/src/lib/hooks'
|
|
|
5
5
|
import { useAppLang } from '../config/i18n'
|
|
6
6
|
import { GlobalProviders } from '../modules/global-providers'
|
|
7
7
|
import { WidgetContainer } from '../modules/widget'
|
|
8
|
+
import { useInitWidget } from '../modules/widget/hooks'
|
|
8
9
|
import type { WidgetSettingProps } from '../types'
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
export type MainProps = {
|
|
12
|
+
settings: WidgetSettingProps
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function Main({ settings }: MainProps) {
|
|
16
|
+
const { completeSetup } = useInitWidget(settings)
|
|
11
17
|
useDefaultId()
|
|
12
18
|
useAppLang(settings.locale)
|
|
13
19
|
|
|
14
20
|
return (
|
|
15
21
|
<ErrorBoundary fallback={<GenericError />}>
|
|
16
22
|
<GlobalProviders settings={settings}>
|
|
17
|
-
<WidgetContainer />
|
|
23
|
+
<WidgetContainer completeSetup={completeSetup} />
|
|
18
24
|
</GlobalProviders>
|
|
19
25
|
</ErrorBoundary>
|
|
20
26
|
)
|
|
@@ -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
|
-
|
|
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
|
|
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,4 @@
|
|
|
1
|
-
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'
|
|
2
2
|
import clsx from 'clsx'
|
|
3
3
|
import type { ChangeEvent, KeyboardEvent } from 'react'
|
|
4
4
|
import { useTranslation } from 'react-i18next'
|
|
@@ -40,11 +40,13 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const setInputFocus = useCallback(() => {
|
|
44
44
|
if (inputDisabled) return
|
|
45
45
|
|
|
46
46
|
const input = ref?.current
|
|
47
47
|
|
|
48
|
+
if (input === document.activeElement) return
|
|
49
|
+
|
|
48
50
|
if (input) {
|
|
49
51
|
input.focus()
|
|
50
52
|
|
|
@@ -53,6 +55,10 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
|
|
53
55
|
}
|
|
54
56
|
}, [inputDisabled])
|
|
55
57
|
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
setInputFocus()
|
|
60
|
+
}, [setInputFocus])
|
|
61
|
+
|
|
56
62
|
return (
|
|
57
63
|
<div
|
|
58
64
|
className={clsx(
|
|
@@ -68,7 +74,7 @@ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
|
|
68
74
|
'max-h-12 w-full resize-none border-none bg-transparent text-neutral-100 outline-none outline-0 placeholder:text-neutral-400',
|
|
69
75
|
styles.textArea
|
|
70
76
|
),
|
|
71
|
-
{ 'cursor-not-allowed
|
|
77
|
+
{ 'cursor-not-allowed opacity-40': inputDisabled }
|
|
72
78
|
)}
|
|
73
79
|
placeholder={t('send_message.field.placeholder')}
|
|
74
80
|
value={value}
|
|
@@ -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
|
})
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
1
2
|
import { useMutation } from '@tanstack/react-query'
|
|
2
3
|
import { v4 } from 'uuid'
|
|
3
4
|
|
|
4
5
|
import { MessagesService } from '@/src/modules/messages'
|
|
5
6
|
import { useGetProfile } from '@/src/modules/profile'
|
|
6
7
|
import { useWidgetLoadingAtom, useWidgetSettingsAtomValue } from '@/src/modules/widget'
|
|
8
|
+
import { MessagesEvents } from '../../events'
|
|
7
9
|
|
|
8
10
|
function useSendTextMessage() {
|
|
9
11
|
const settings = useWidgetSettingsAtomValue()
|
|
10
12
|
const profileQuery = useGetProfile()
|
|
11
13
|
const [, setWidgetLoading] = useWidgetLoadingAtom()
|
|
12
14
|
|
|
15
|
+
const userId = useMemo(() => profileQuery.data?.userId?.toString(), [profileQuery.data?.userId])
|
|
16
|
+
|
|
13
17
|
return useMutation({
|
|
14
18
|
mutationFn(message: string) {
|
|
15
19
|
let processedMessage = message
|
|
@@ -47,12 +51,13 @@ function useSendTextMessage() {
|
|
|
47
51
|
externalId: v4(),
|
|
48
52
|
namespace: settings.namespace,
|
|
49
53
|
sessionId: settings.sessionId,
|
|
50
|
-
userId
|
|
54
|
+
userId
|
|
51
55
|
}
|
|
52
56
|
})
|
|
53
57
|
},
|
|
54
58
|
onMutate: () => {
|
|
55
59
|
setWidgetLoading(true)
|
|
60
|
+
MessagesEvents.get('c3po-chat:questionSubmitted')?.dispatch()
|
|
56
61
|
}
|
|
57
62
|
})
|
|
58
63
|
}
|
|
@@ -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
|
-
}, [
|
|
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)
|
|
@@ -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
|
|
238
|
+
if (this.sparkie) {
|
|
229
239
|
await this.sparkieInstance.destroy({ skipSignOut: true })
|
|
230
240
|
}
|
|
231
|
-
} catch
|
|
232
|
-
console.
|
|
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,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,9 +1,11 @@
|
|
|
1
1
|
import { ChatPage } from './chat-page'
|
|
2
|
+
import { WidgetLoadingPage } from './loading-page'
|
|
2
3
|
import { WidgetOnboardingPage } from './onboarding-page'
|
|
3
4
|
import { WidgetStarterPage } from './starter-page'
|
|
4
5
|
|
|
5
6
|
export const WIDGET_TABS = {
|
|
6
7
|
onboarding: <WidgetOnboardingPage />,
|
|
7
8
|
starter: <WidgetStarterPage />,
|
|
8
|
-
chat: <ChatPage
|
|
9
|
+
chat: <ChatPage />,
|
|
10
|
+
loading: <WidgetLoadingPage />
|
|
9
11
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
1
3
|
import { useSubscribeMessageReceivedEvent } from '@/src/modules/messages/hooks'
|
|
2
|
-
import {
|
|
4
|
+
import { useSubscribeThreadClosedEvent } from '@/src/modules/thread/hooks'
|
|
5
|
+
import { useListenToVisibilityEvents } from '../../hooks'
|
|
6
|
+
import { useWidgetTabsAtom } from '../../store'
|
|
3
7
|
import { WIDGET_TABS } from '../constants'
|
|
4
8
|
|
|
5
|
-
function WidgetContainer() {
|
|
6
|
-
const widgetTabs =
|
|
9
|
+
function WidgetContainer({ completeSetup = false }: { completeSetup?: boolean }) {
|
|
10
|
+
const [widgetTabs, setTab] = useWidgetTabsAtom()
|
|
11
|
+
|
|
7
12
|
useSubscribeMessageReceivedEvent()
|
|
13
|
+
useSubscribeThreadClosedEvent()
|
|
14
|
+
useListenToVisibilityEvents()
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (completeSetup) {
|
|
18
|
+
setTab('chat')
|
|
19
|
+
}
|
|
20
|
+
}, [completeSetup, setTab])
|
|
8
21
|
|
|
9
22
|
return (
|
|
10
23
|
<div className='flex h-full flex-col items-center justify-stretch overflow-hidden'>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as WidgetLoadingPage } from './loading-page'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useRefEventListener } from '@/src/lib/hooks'
|
|
4
|
+
import {
|
|
5
|
+
ChatInput,
|
|
6
|
+
MessageSkeleton,
|
|
7
|
+
useChatInputValueAtom
|
|
8
|
+
} from '@/src/modules/messages/components'
|
|
9
|
+
import { PageLayout } from '../page-layout'
|
|
10
|
+
|
|
11
|
+
function WidgetLoadingPage() {
|
|
12
|
+
const chatInputRef = useRef<HTMLTextAreaElement>(null)
|
|
13
|
+
const [, setChatInputValue] = useChatInputValueAtom()
|
|
14
|
+
|
|
15
|
+
const handler = useCallback(
|
|
16
|
+
(e: Event) => {
|
|
17
|
+
const target = e.target as HTMLTextAreaElement
|
|
18
|
+
setChatInputValue(target.value)
|
|
19
|
+
},
|
|
20
|
+
[setChatInputValue]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
useRefEventListener<HTMLTextAreaElement>({
|
|
24
|
+
config: {
|
|
25
|
+
ref: chatInputRef,
|
|
26
|
+
eventTypes: ['input', 'change'],
|
|
27
|
+
handler
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<PageLayout
|
|
33
|
+
asideChild={<ChatInput name='new-chat-msg-input' ref={chatInputRef} loading={true} />}>
|
|
34
|
+
<div className='flex h-full flex-col justify-end px-5 py-4'>
|
|
35
|
+
<MessageSkeleton />
|
|
36
|
+
</div>
|
|
37
|
+
</PageLayout>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default WidgetLoadingPage
|
|
@@ -6,33 +6,49 @@ export const TutorWidgetEventTypes = {
|
|
|
6
6
|
LOADED: 'tutor-app-widget-loaded'
|
|
7
7
|
} as const
|
|
8
8
|
|
|
9
|
-
const
|
|
10
|
-
{
|
|
9
|
+
const TutorWidgetEventsObject = {
|
|
10
|
+
[TutorWidgetEventTypes.OPEN]: {
|
|
11
11
|
name: TutorWidgetEventTypes.OPEN,
|
|
12
|
-
handler: () =>
|
|
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
|
+
},
|
|
13
23
|
dispatch: () => {
|
|
14
24
|
window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.OPEN))
|
|
15
25
|
}
|
|
16
|
-
}
|
|
17
|
-
|
|
26
|
+
} as ITutorWidgetEvent<void>,
|
|
27
|
+
|
|
28
|
+
[TutorWidgetEventTypes.CLOSE]: {
|
|
18
29
|
name: TutorWidgetEventTypes.CLOSE,
|
|
19
|
-
handler: () => () => undefined,
|
|
20
|
-
dispatch: () => {
|
|
21
|
-
window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.CLOSE))
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
name: TutorWidgetEventTypes.LOADED,
|
|
26
30
|
handler: (callback) => {
|
|
27
|
-
const listener: EventListener = (
|
|
28
|
-
|
|
31
|
+
const listener: EventListener = () => {
|
|
32
|
+
void callback()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
window.addEventListener(TutorWidgetEventTypes.CLOSE, listener)
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
return () => {
|
|
38
|
+
window.removeEventListener(TutorWidgetEventTypes.CLOSE, listener)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
dispatch: () => window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.CLOSE))
|
|
42
|
+
} as ITutorWidgetEvent<void>,
|
|
31
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 }>
|
|
32
49
|
callback(evt.detail)
|
|
33
50
|
}
|
|
34
51
|
window.addEventListener(TutorWidgetEventTypes.LOADED, listener)
|
|
35
|
-
|
|
36
52
|
return () => {
|
|
37
53
|
window.removeEventListener(TutorWidgetEventTypes.LOADED, listener)
|
|
38
54
|
}
|
|
@@ -41,9 +57,9 @@ const TutorWidgetEventsList = [
|
|
|
41
57
|
window.dispatchEvent(new CustomEvent(TutorWidgetEventTypes.LOADED, payload))
|
|
42
58
|
}
|
|
43
59
|
} as ITutorWidgetEvent<{ isSuccess: boolean }>
|
|
44
|
-
|
|
60
|
+
} as const
|
|
45
61
|
|
|
46
|
-
export const TutorWidgetEvents =
|
|
62
|
+
export const TutorWidgetEvents = TutorWidgetEventsObject
|
|
47
63
|
|
|
48
64
|
export const ACTION_EVENTS = {
|
|
49
65
|
SCROLL: 'c3po-app-widget-scroll-to-bottom'
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export * from './use-init-
|
|
1
|
+
export * from './use-init-widget'
|
|
2
|
+
export * from './use-listen-to-visibility-events'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as useInitWidget } from './use-init-widget'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { initDayjs } from '@/src/config/dayjs'
|
|
4
|
+
import { initAxios } from '@/src/config/request/api'
|
|
5
|
+
import { SparkieService } from '@/src/modules/sparkie'
|
|
6
|
+
import type { WidgetSettingProps } from '@/src/types'
|
|
7
|
+
import { TutorWidgetEvents } from '../../events'
|
|
8
|
+
|
|
9
|
+
const init = async (settings: WidgetSettingProps) => {
|
|
10
|
+
try {
|
|
11
|
+
initAxios(settings.hotmartToken)
|
|
12
|
+
await initDayjs(settings.locale)
|
|
13
|
+
await SparkieService.initSparkie({
|
|
14
|
+
token: settings?.hotmartToken,
|
|
15
|
+
skipPresenceSetup: true,
|
|
16
|
+
retryOptions: {
|
|
17
|
+
maxRetries: 5,
|
|
18
|
+
retryDelay: 2000,
|
|
19
|
+
backoffMultiplier: 1.5
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
await SparkieService.ensureInitialized()
|
|
23
|
+
TutorWidgetEvents['tutor-app-widget-loaded'].dispatch()
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(error)
|
|
26
|
+
TutorWidgetEvents['tutor-app-widget-loaded'].dispatch({ detail: { isSuccess: false } })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function useInitWidget(settings: WidgetSettingProps) {
|
|
31
|
+
const [completeSetup, setCompleteSetup] = useState(false)
|
|
32
|
+
const [error, setError] = useState<unknown>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (completeSetup) return
|
|
36
|
+
|
|
37
|
+
init(settings)
|
|
38
|
+
.then(() => {
|
|
39
|
+
setCompleteSetup(true)
|
|
40
|
+
})
|
|
41
|
+
.catch(setError)
|
|
42
|
+
}, [completeSetup, settings])
|
|
43
|
+
|
|
44
|
+
return { completeSetup, error }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default useInitWidget
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as useListenToVisibilityEvents } from './use-listen-to-visibility-events'
|
package/src/modules/widget/hooks/use-listen-to-visibility-events/use-listen-to-visibility-events.tsx
ADDED
|
@@ -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: '
|
|
12
|
-
history: new Set(['
|
|
11
|
+
currentTab: 'loading',
|
|
12
|
+
history: new Set(['loading'])
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export const widgetTabsAtom = atom<WidgetTabsProps>(INITIAL_PROPS)
|
|
@@ -2,6 +2,6 @@ import type { TutorWidgetEventTypes } from './events'
|
|
|
2
2
|
|
|
3
3
|
export type ITutorWidgetEvent<T = unknown> = {
|
|
4
4
|
name: (typeof TutorWidgetEventTypes)[keyof typeof TutorWidgetEventTypes]
|
|
5
|
-
handler: (callback: (payload: T) => void) => () => void
|
|
5
|
+
handler: (callback: (payload: T) => void | Promise<void>) => () => void
|
|
6
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
|
-
}
|