app-tutor-ai-consumer 1.5.0 → 1.7.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 (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/config/vitest/__mocks__/sparkie.tsx +9 -0
  3. package/eslint.config.mjs +27 -0
  4. package/package.json +7 -2
  5. package/src/@types/index.d.ts +5 -2
  6. package/src/config/styles/global.css +3 -2
  7. package/src/config/tanstack/query-client.ts +1 -1
  8. package/src/development-bootstrap.tsx +15 -15
  9. package/src/index.tsx +15 -5
  10. package/src/lib/components/icons/ai-color.svg +17 -0
  11. package/src/lib/components/icons/icon-names.d.ts +1 -1
  12. package/src/lib/components/icons/stop.svg +4 -0
  13. package/src/lib/utils/is-text-empty.ts +3 -0
  14. package/src/main/main.spec.tsx +0 -8
  15. package/src/modules/messages/components/chat-input/chat-input.spec.tsx +76 -0
  16. package/src/modules/messages/components/chat-input/chat-input.tsx +100 -23
  17. package/src/modules/messages/components/chat-input/styles.module.css +3 -0
  18. package/src/modules/messages/components/chat-input/types.ts +3 -0
  19. package/src/modules/messages/components/index.ts +1 -0
  20. package/src/modules/messages/components/message-skeleton/index.ts +1 -0
  21. package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +23 -0
  22. package/src/modules/messages/components/messages-list/messages-list.tsx +14 -1
  23. package/src/modules/messages/hooks/index.ts +2 -0
  24. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +7 -0
  25. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +7 -23
  26. package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +5 -1
  27. package/src/modules/messages/hooks/use-send-text-message/index.ts +2 -0
  28. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +86 -0
  29. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +60 -0
  30. package/src/modules/messages/hooks/use-skeleton-ref/index.ts +2 -0
  31. package/src/modules/messages/hooks/use-skeleton-ref/use-skeleton-ref.tsx +34 -0
  32. package/src/modules/messages/hooks/use-subscribe-message-received-event/index.ts +2 -0
  33. package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +80 -0
  34. package/src/modules/messages/service.ts +8 -7
  35. package/src/modules/sparkie/service.ts +182 -35
  36. package/src/modules/sparkie/types.ts +10 -2
  37. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +23 -1
  38. package/src/modules/widget/components/ai-avatar/ai-avatar.tsx +11 -53
  39. package/src/modules/widget/components/chat-page/chat-page.tsx +31 -3
  40. package/src/modules/widget/components/container/container.tsx +4 -24
  41. package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +1 -1
  42. package/src/modules/widget/components/starter-page/starter-page.tsx +3 -3
  43. package/src/modules/widget/store/index.ts +2 -0
  44. package/src/modules/widget/store/widget-loading.atom.ts +11 -0
  45. package/src/modules/widget/store/widget-scrolling.atom.ts +11 -0
  46. package/src/modules/widget/store/widget-tabs.atom.ts +2 -1
  47. package/src/types.ts +4 -1
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useState } from 'react'
2
2
  import type { RefObject } from 'react'
3
3
 
4
+ import { useWidgetScrollingAtom } from '@/src/modules/widget'
4
5
  import { useAllMessages } from '../use-all-messages'
5
6
 
6
7
  const threshold = 250 // min scroll pos to show scroller button
@@ -9,6 +10,7 @@ function useManageScroll<T extends HTMLElement>(scrollerRef: RefObject<T | null>
9
10
  const { messagesQuery, hasMessages } = useAllMessages()
10
11
  const [heightBeforeRender, setHeightBeforeRender] = useState(0)
11
12
  const [showScrollButton, setShowScrollButton] = useState(false)
13
+ const [, setWidgetScrolling] = useWidgetScrollingAtom()
12
14
 
13
15
  const handleScroll = useCallback(() => {
14
16
  const scroller = scrollerRef.current
@@ -39,6 +41,7 @@ function useManageScroll<T extends HTMLElement>(scrollerRef: RefObject<T | null>
39
41
  if (!scroller || !hasMessages || !messagesQuery.isSuccess) return
40
42
 
41
43
  const handleShowBtn = () => {
44
+ setWidgetScrolling(true)
42
45
  const scrollPosition =
43
46
  Number(scroller.scrollHeight) - Number(scroller.scrollTop) - Number(scroller.clientHeight)
44
47
 
@@ -49,8 +52,9 @@ function useManageScroll<T extends HTMLElement>(scrollerRef: RefObject<T | null>
49
52
 
50
53
  return () => {
51
54
  scroller.removeEventListener('scroll', handleShowBtn)
55
+ setWidgetScrolling(false)
52
56
  }
53
- }, [hasMessages, messagesQuery.isSuccess, scrollerRef])
57
+ }, [hasMessages, messagesQuery.isSuccess, scrollerRef, setWidgetScrolling])
54
58
 
55
59
  useEffect(() => {
56
60
  const scroller = scrollerRef.current
@@ -0,0 +1,2 @@
1
+ export * from './use-send-text-message'
2
+ export { default as useSendTextMessage } from './use-send-text-message'
@@ -0,0 +1,86 @@
1
+ import { act, chance, renderHook, waitFor } from '@/src/config/tests'
2
+ import { MessagesService } from '@/src/modules/messages'
3
+ import { useGetProfile } from '@/src/modules/profile'
4
+ import * as Store from '@/src/modules/widget'
5
+ import WidgetSettingPropsBuilder from '@/src/modules/widget/__tests__/widget-settings-props.builder'
6
+
7
+ import useSendTextMessage from './use-send-text-message'
8
+
9
+ vi.mock('@/src/modules/profile', () => ({
10
+ useGetProfile: vi.fn()
11
+ }))
12
+
13
+ describe('useSendTextMessage', () => {
14
+ const message = chance.animal()
15
+ const getProfileMock = { data: { userId: chance.integer() } }
16
+ const defaultSettings = new WidgetSettingPropsBuilder()
17
+ .withClassHashId(chance.guid())
18
+ .withOwner_id(chance.guid())
19
+ .withCurrent_media_codes(chance.profession())
20
+
21
+ const render = () => renderHook(useSendTextMessage)
22
+
23
+ beforeEach(() => {
24
+ vi.mocked(useGetProfile).mockReturnValue(getProfileMock as never)
25
+ vi.spyOn(Store, 'useWidgetSettingsAtomValue').mockReturnValue(defaultSettings)
26
+ })
27
+
28
+ it('should throw when conversationId is not defined', async () => {
29
+ vi.spyOn(Store, 'useWidgetSettingsAtomValue').mockReturnValueOnce({} as never)
30
+
31
+ const { result } = render()
32
+
33
+ await act(() =>
34
+ expect(result.current.mutateAsync(message)).rejects.toEqual(
35
+ new Error('Conversation Id must be defined')
36
+ )
37
+ )
38
+ })
39
+
40
+ it('should successfully send a text message', async () => {
41
+ const { result } = render()
42
+
43
+ await waitFor(() => result.current.mutateAsync(message))
44
+
45
+ expect(result.current.isSuccess).toBeTruthy()
46
+ })
47
+
48
+ it('should be properly able to handle question::<text> messages', async () => {
49
+ const txt = 'question::summary'
50
+ const text = txt.replace('question::', '')
51
+
52
+ vi.spyOn(MessagesService, 'sendTextMessage')
53
+
54
+ const { result } = render()
55
+
56
+ await waitFor(() => result.current.mutateAsync(txt))
57
+
58
+ expect(MessagesService.sendTextMessage).toHaveBeenCalledTimes(1)
59
+ expect(MessagesService.sendTextMessage).toHaveBeenNthCalledWith(1, {
60
+ content: {
61
+ type: 'text/plain',
62
+ text
63
+ },
64
+ conversationId: defaultSettings.conversationId,
65
+ metadata: {
66
+ author: 'user',
67
+ contactId: defaultSettings.contactId,
68
+ context: {
69
+ language: defaultSettings.locale,
70
+ clubName: defaultSettings.clubName,
71
+ productName: defaultSettings.productName,
72
+ productId: defaultSettings.productId,
73
+ classHashId: defaultSettings.classHashId,
74
+ owner_id: defaultSettings.owner_id,
75
+ current_media_codes: defaultSettings.current_media_codes,
76
+ question: text,
77
+ router: text
78
+ },
79
+ externalId: expect.any(String),
80
+ namespace: defaultSettings.namespace,
81
+ sessionId: defaultSettings.sessionId,
82
+ userId: String(getProfileMock.data.userId)
83
+ }
84
+ })
85
+ })
86
+ })
@@ -0,0 +1,60 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+ import { v4 } from 'uuid'
3
+
4
+ import { MessagesService } from '@/src/modules/messages'
5
+ import { useGetProfile } from '@/src/modules/profile'
6
+ import { useWidgetLoadingAtom, useWidgetSettingsAtomValue } from '@/src/modules/widget'
7
+
8
+ function useSendTextMessage() {
9
+ const settings = useWidgetSettingsAtomValue()
10
+ const profileQuery = useGetProfile()
11
+ const [, setWidgetLoading] = useWidgetLoadingAtom()
12
+
13
+ return useMutation({
14
+ mutationFn(message: string) {
15
+ let processedMessage = message
16
+
17
+ if (!settings?.conversationId) throw new Error('Conversation Id must be defined')
18
+
19
+ const questionRegex = /^question::\s*/i
20
+ let questionParam: string | undefined
21
+
22
+ if (questionRegex.test(message)) {
23
+ processedMessage = message.replace(questionRegex, '').trim()
24
+ questionParam = processedMessage
25
+ }
26
+
27
+ return MessagesService.sendTextMessage({
28
+ content: {
29
+ type: 'text/plain',
30
+ text: processedMessage
31
+ },
32
+ conversationId: settings?.conversationId,
33
+ metadata: {
34
+ author: 'user',
35
+ contactId: settings.contactId,
36
+ context: {
37
+ language: settings.locale,
38
+ clubName: settings.clubName,
39
+ productName: settings.productName,
40
+ productId: settings.productId,
41
+ classHashId: settings.classHashId,
42
+ owner_id: settings?.owner_id,
43
+ current_media_codes: settings?.current_media_codes,
44
+ question: questionParam,
45
+ router: questionParam ? 'summary' : undefined
46
+ },
47
+ externalId: v4(),
48
+ namespace: settings.namespace,
49
+ sessionId: settings.sessionId,
50
+ userId: profileQuery.data?.userId?.toString()
51
+ }
52
+ })
53
+ },
54
+ onMutate: () => {
55
+ setWidgetLoading(true)
56
+ }
57
+ })
58
+ }
59
+
60
+ export default useSendTextMessage
@@ -0,0 +1,2 @@
1
+ export * from './use-skeleton-ref'
2
+ export { default as useSkeletonRef } from './use-skeleton-ref'
@@ -0,0 +1,34 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import { useWidgetLoadingAtomValue } from '@/src/modules/widget'
4
+
5
+ export function useSkeletonRef() {
6
+ const skeletonRef = useRef<HTMLDivElement>(null)
7
+ const isLoading = useWidgetLoadingAtomValue()
8
+
9
+ useEffect(() => {
10
+ const skeleton = skeletonRef.current
11
+
12
+ if (!skeleton) return
13
+
14
+ const observer = new IntersectionObserver(
15
+ ([entry]) => {
16
+ if (!isLoading || !entry || entry.isIntersecting) return
17
+
18
+ entry.target.scrollIntoView({ behavior: 'smooth', block: 'end' })
19
+ },
20
+ { threshold: 1 }
21
+ )
22
+
23
+ const timeout = setTimeout(() => observer.observe(skeleton), 50)
24
+
25
+ return () => {
26
+ clearTimeout(timeout)
27
+ observer.disconnect()
28
+ }
29
+ }, [isLoading])
30
+
31
+ return skeletonRef
32
+ }
33
+
34
+ export default useSkeletonRef
@@ -0,0 +1,2 @@
1
+ export * from './use-subscribe-message-received-event'
2
+ export { default as useSubscribeMessageReceivedEvent } from './use-subscribe-message-received-event'
@@ -0,0 +1,80 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import type { InfiniteData } from '@tanstack/react-query'
3
+ import { useQueryClient } from '@tanstack/react-query'
4
+ import { produce } from 'immer'
5
+
6
+ import { useGetProfile } from '@/src/modules/profile'
7
+ import { SparkieService } from '@/src/modules/sparkie'
8
+ import { useWidgetLoadingAtom, useWidgetSettingsAtom } from '@/src/modules/widget'
9
+ import type { FetchMessagesResponse, IMessageWithSenderData } from '../../types'
10
+ import { getMessagesInfiniteQuery } from '../use-infinite-get-messages'
11
+
12
+ const useSubscribeMessageReceivedEvent = () => {
13
+ const [settings] = useWidgetSettingsAtom()
14
+ const profileQuery = useGetProfile()
15
+ const queryClient = useQueryClient()
16
+ const [, setWidgetLoading] = useWidgetLoadingAtom()
17
+
18
+ const conversationId = useMemo(() => String(settings?.conversationId), [settings?.conversationId])
19
+ const profileId = useMemo(() => String(profileQuery?.data?.id), [profileQuery?.data?.id])
20
+
21
+ const query = getMessagesInfiniteQuery({
22
+ conversationId,
23
+ profileId,
24
+ enabled: true
25
+ })
26
+
27
+ useEffect(() => {
28
+ const messageReceived = (data: IMessageWithSenderData) => {
29
+ const queryKey = query.queryKey
30
+
31
+ if (!queryKey || !queryClient) return
32
+
33
+ const previousData = queryClient.getQueryData<InfiniteData<FetchMessagesResponse>>(
34
+ query.queryKey
35
+ )
36
+
37
+ if (!previousData) return
38
+
39
+ const pages = previousData?.pages
40
+
41
+ if (!(Number(pages?.length) > 0)) return
42
+
43
+ const currentMsgIds = new Set(pages.flatMap((page) => page.messages.map((msg) => msg.id)))
44
+
45
+ if (currentMsgIds.has(data.id)) return
46
+
47
+ queryClient.setQueryData<InfiniteData<FetchMessagesResponse>>(queryKey, (prev) => {
48
+ if (!prev) return prev
49
+
50
+ const result = produce(prev, (draft) => {
51
+ if (!(Number(draft?.pages?.at?.(-1)?.messages?.length) > 0)) return draft
52
+
53
+ draft.pages.at(-1)?.messages.push(data)
54
+
55
+ return draft
56
+ })
57
+
58
+ return result
59
+ })
60
+
61
+ const isMine = data.contactId === profileId
62
+
63
+ if (!isMine) {
64
+ setTimeout(() => setWidgetLoading(false), 100)
65
+ }
66
+ }
67
+
68
+ SparkieService.subscribeEvents({
69
+ messageReceived
70
+ })
71
+
72
+ return () => {
73
+ SparkieService.removeEventSubscription({
74
+ messageReceived
75
+ })
76
+ }
77
+ }, [profileId, query.queryKey, queryClient, setWidgetLoading])
78
+ }
79
+
80
+ export default useSubscribeMessageReceivedEvent
@@ -15,9 +15,9 @@ import type {
15
15
  } from './types'
16
16
 
17
17
  class MessagesService {
18
- private get sparkieMessageService() {
18
+ async getSparkieMessageService() {
19
19
  try {
20
- const messageService = SparkieService.sparkieInstance.messageService
20
+ const messageService = await SparkieService.getMessageService()
21
21
 
22
22
  if (!messageService) throw new Error()
23
23
 
@@ -35,7 +35,8 @@ class MessagesService {
35
35
  conversationId,
36
36
  before
37
37
  }: IGetMessagesPayload): Promise<Array<IMessageWithSenderData>> {
38
- const data = await this.sparkieMessageService.getAll(conversationId, {
38
+ const sparkieMessageService = await this.getSparkieMessageService()
39
+ const data = await sparkieMessageService.getAll(conversationId, {
39
40
  before
40
41
  })
41
42
 
@@ -66,7 +67,8 @@ class MessagesService {
66
67
  conversationId,
67
68
  metadata
68
69
  }: ISendTextMessagePayload): Promise<Message> {
69
- const data = await this.sparkieMessageService.post(conversationId, {
70
+ const sparkieMessageService = await this.getSparkieMessageService()
71
+ const data = await sparkieMessageService.post(conversationId, {
70
72
  content,
71
73
  metadata
72
74
  })
@@ -75,9 +77,8 @@ class MessagesService {
75
77
  }
76
78
 
77
79
  async sendImageMessage({ content, conversationId }: ISendImageMessagePayload): Promise<Message> {
78
- const data = await this.sparkieMessageService.post(conversationId, {
79
- content
80
- })
80
+ const sparkieMessageService = await this.getSparkieMessageService()
81
+ const data = await sparkieMessageService.post(conversationId, { content })
81
82
 
82
83
  return data
83
84
  }
@@ -3,11 +3,20 @@ import Sparkie from '@hotmart/sparkie'
3
3
  import { MSG_MAX_COUNT } from '../messages'
4
4
 
5
5
  import { ACTION_EVENT_MAP, firebaseConfig } from './constants'
6
- import type { InitSparkiePayload, SparkieActions, TrackTypingPayload } from './types'
6
+ import type {
7
+ InitializationState,
8
+ InitSparkiePayload,
9
+ RetryOptions,
10
+ SparkieActions,
11
+ TrackTypingPayload
12
+ } from './types'
7
13
  import { validateFirebaseConfig } from './utils/validate-firebase-config'
8
14
 
9
15
  class SparkieService {
10
16
  private sparkie: Sparkie | null = null
17
+ private initializationState: InitializationState = 'idle'
18
+ private initializationPromise: Promise<boolean> | null = null
19
+ private lastInitToken: string | null = null
11
20
 
12
21
  constructor() {
13
22
  if (validateFirebaseConfig()) {
@@ -29,33 +38,166 @@ class SparkieService {
29
38
  return this.sparkie
30
39
  }
31
40
 
32
- async initSparkie({ token, skipPresenceSetup = false }: InitSparkiePayload): Promise<boolean> {
33
- const sparkie = this.sparkieInstance
41
+ get isInitialized(): boolean {
42
+ return (
43
+ this.initializationState === 'initialized' &&
44
+ this.sparkie?.messageService?.getAll !== undefined
45
+ )
46
+ }
34
47
 
35
- try {
36
- if (!token || !token.trim())
37
- throw new Error('Invalid or missing token for Sparkie initialization')
48
+ get initState(): InitializationState {
49
+ return this.initializationState
50
+ }
51
+
52
+ get status() {
53
+ return {
54
+ state: this.initializationState,
55
+ isInitialized: this.isInitialized,
56
+ hasToken: !!this.lastInitToken,
57
+ hasMessageService: !!this.sparkie?.messageService?.getAll
58
+ }
59
+ }
38
60
 
39
- await sparkie.init(token, { skipPresenceSetup })
61
+ async initSparkie({
62
+ token,
63
+ skipPresenceSetup = false,
64
+ retryOptions = {}
65
+ }: InitSparkiePayload & { retryOptions?: RetryOptions }): Promise<boolean> {
66
+ const { maxRetries = 3, backoffMultiplier = 2, retryDelay = 1000 } = retryOptions
67
+
68
+ if (this.initializationPromise && this.lastInitToken === token) {
69
+ return this.initializationPromise
70
+ }
71
+
72
+ // if already initialized with a different token, reinitialize
73
+ if (this.isInitialized && this.lastInitToken !== token) {
74
+ await this.destroySparkie()
75
+ this.initializationState = 'idle'
76
+ }
77
+
78
+ // already initialized with the current token
79
+ if (this.isInitialized && this.lastInitToken === token) {
40
80
  return true
41
- } catch (error) {
42
- console.error('Error initializing realtime engine', error)
43
-
44
- if (error instanceof Error) {
45
- console.error('Error details:', {
46
- message: error.message,
47
- stack: error.stack,
48
- token: token ? 'provided' : 'missing',
49
- skipPresenceSetup
50
- })
81
+ }
82
+
83
+ this.lastInitToken = token
84
+ this.initializationState = 'initializing'
85
+
86
+ this.initializationPromise = this.performInitializationWithRetry({
87
+ token,
88
+ skipPresenceSetup,
89
+ maxRetries,
90
+ retryDelay,
91
+ backoffMultiplier
92
+ })
93
+
94
+ const result = await this.initializationPromise
95
+
96
+ this.initializationPromise = null
97
+
98
+ return result
99
+ }
100
+
101
+ private async performInitializationWithRetry({
102
+ token,
103
+ skipPresenceSetup,
104
+ maxRetries,
105
+ retryDelay,
106
+ backoffMultiplier
107
+ }: InitSparkiePayload & Required<RetryOptions>): Promise<boolean> {
108
+ let currentDelay = retryDelay
109
+ let attempt = 1
110
+
111
+ while (attempt <= maxRetries) {
112
+ try {
113
+ if (!token || !token.trim())
114
+ throw new Error('Invalid or missing token for Sparkie initialization')
115
+
116
+ const sparkie = this.sparkieInstance
117
+ const service = await sparkie.init(token, { skipPresenceSetup })
118
+ const isServiceReady = typeof service.messageService?.getAll === 'function'
119
+
120
+ if (!isServiceReady) throw new Error('MessageService not properly initialized after init')
121
+
122
+ this.initializationState = 'initialized'
123
+
124
+ return true
125
+ } catch (error) {
126
+ console.error('Error initializing realtime engine', error)
127
+
128
+ if (error instanceof Error) {
129
+ console.error('Error details:', {
130
+ message: error.message,
131
+ stack: error.stack,
132
+ token: token ? 'provided' : 'missing',
133
+ skipPresenceSetup
134
+ })
135
+ }
136
+
137
+ if (attempt === maxRetries) {
138
+ this.initializationState = 'failed'
139
+ console.error(
140
+ `❌ Sparkie initialization attempts reached the maxRetries: $${maxRetries} amount`,
141
+ error
142
+ )
143
+ return false
144
+ }
145
+
146
+ await new Promise((resolve) => setTimeout(resolve, currentDelay))
147
+ currentDelay *= backoffMultiplier
148
+ } finally {
149
+ attempt += 1
51
150
  }
151
+ }
52
152
 
53
- return false
153
+ return false
154
+ }
155
+
156
+ private async ensureInitialized(): Promise<void> {
157
+ if (this.isInitialized) return
158
+
159
+ switch (this.initializationState) {
160
+ case 'failed':
161
+ case 'idle':
162
+ throw new Error('Sparkie initialization failed. Please call initSparkie() first.')
163
+ case 'initializing':
164
+ if (this.initializationPromise) {
165
+ const success = await this.initializationPromise
166
+ if (!success) throw new Error('Sparkie initialization failed')
167
+ }
54
168
  }
55
169
  }
56
170
 
57
- updateToken(token: string): void {
171
+ async getMessageService() {
172
+ await this.ensureInitialized()
173
+
174
+ const messageService = this.sparkieInstance.messageService
175
+
176
+ if (!messageService) throw new Error('MessageService not available after initialization')
177
+
178
+ return messageService
179
+ }
180
+
181
+ async updateToken(token: string, reinitialize = true): Promise<boolean> {
58
182
  this.sparkieInstance.setAPIToken(token)
183
+
184
+ if (reinitialize && token !== this.lastInitToken) {
185
+ return this.initSparkie({ token })
186
+ }
187
+
188
+ return true
189
+ }
190
+
191
+ subscribeEvents(actions: SparkieActions): void {
192
+ const { messageReceived, threadClosed, threadJoined, contactTyping } = actions
193
+
194
+ if (messageReceived) this.sparkieInstance.on(ACTION_EVENT_MAP.messageReceived, messageReceived)
195
+
196
+ if (threadClosed) this.sparkieInstance.on(ACTION_EVENT_MAP.threadClosed, threadClosed)
197
+
198
+ if (threadJoined) this.sparkieInstance.on(ACTION_EVENT_MAP.threadJoined, threadJoined)
199
+
200
+ if (contactTyping) this.sparkieInstance.on(ACTION_EVENT_MAP.contactTyping, contactTyping)
59
201
  }
60
202
 
61
203
  removeEventSubscription(actions: SparkieActions): void {
@@ -67,27 +209,32 @@ class SparkieService {
67
209
  })
68
210
  }
69
211
 
70
- subscribeEvents({
71
- messageReceived,
72
- threadClosed,
73
- threadJoined,
74
- contactTyping
75
- }: SparkieActions): void {
76
- if (messageReceived) this.sparkieInstance.on(ACTION_EVENT_MAP.messageReceived, messageReceived)
212
+ async trackTyping({ contactIds, conversationId }: TrackTypingPayload): Promise<void> {
213
+ await this.ensureInitialized()
214
+ this.sparkieInstance.listener?.trackTyping(conversationId, contactIds)
215
+ }
77
216
 
78
- if (threadClosed) this.sparkieInstance.on(ACTION_EVENT_MAP.threadClosed, threadClosed)
217
+ async reinitialize(): Promise<boolean> {
218
+ if (!this.lastInitToken) throw new Error('No token available for reinitialization')
79
219
 
80
- if (threadJoined) this.sparkieInstance.on(ACTION_EVENT_MAP.threadJoined, threadJoined)
220
+ await this.destroySparkie()
221
+ this.initializationState = 'idle'
81
222
 
82
- if (contactTyping) this.sparkieInstance.on(ACTION_EVENT_MAP.contactTyping, contactTyping)
223
+ return this.initSparkie({ token: this.lastInitToken })
83
224
  }
84
225
 
85
- trackTyping({ contactIds, conversationId }: TrackTypingPayload): void {
86
- this.sparkieInstance.listener?.trackTyping(conversationId, contactIds)
87
- }
88
-
89
- destroySparkie(): Promise<void> {
90
- return this.sparkieInstance.destroy({ skipSignOut: true })
226
+ async destroySparkie(): Promise<void> {
227
+ try {
228
+ if (this.sparkie && this.initializationState !== 'idle') {
229
+ await this.sparkieInstance.destroy({ skipSignOut: true })
230
+ }
231
+ } catch (error) {
232
+ console.error('Error destroying Sparkie:', error)
233
+ } finally {
234
+ this.initializationState = 'idle'
235
+ this.initializationPromise = null
236
+ this.lastInitToken = null
237
+ }
91
238
  }
92
239
  }
93
240
 
@@ -1,4 +1,4 @@
1
- import type { IMessage } from '../messages'
1
+ import type { IMessageWithSenderData } from '../messages'
2
2
 
3
3
  export type InitSparkiePayload = {
4
4
  token: string
@@ -7,6 +7,14 @@ export type InitSparkiePayload = {
7
7
  includeSupportEvents?: boolean
8
8
  }
9
9
 
10
+ export type InitializationState = 'idle' | 'initializing' | 'initialized' | 'failed'
11
+
12
+ export interface RetryOptions {
13
+ maxRetries?: number
14
+ retryDelay?: number
15
+ backoffMultiplier?: number
16
+ }
17
+
10
18
  type ThreadMetadata = Record<string, unknown>
11
19
 
12
20
  export type IContact = {
@@ -35,7 +43,7 @@ export type ITyping = {
35
43
  }
36
44
 
37
45
  export type SparkieActions = {
38
- messageReceived?: (data: IMessage) => void
46
+ messageReceived?: (data: IMessageWithSenderData) => void
39
47
  threadClosed?: (data: IThread) => void
40
48
  threadJoined?: (data: IThread) => void
41
49
  contactTyping?: (data: ITyping) => void
@@ -12,12 +12,15 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
12
12
  clubName?: string
13
13
  contactId?: string
14
14
  namespace?: string
15
- sessionId?: string
15
+ sessionId: string
16
16
  membershipId?: string
17
17
  membershipSlug?: string
18
18
  userId?: string
19
19
  tutorName?: string
20
20
  user?: User
21
+ classHashId?: string
22
+ owner_id?: string
23
+ current_media_codes?: string
21
24
 
22
25
  constructor() {
23
26
  this.hotmartToken = chance.guid()
@@ -25,6 +28,7 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
25
28
  this.conversationId = chance.guid()
26
29
  this.productId = 4234
27
30
  this.productName = 'Berim CDs'
31
+ this.sessionId = chance.guid()
28
32
  this.conversationId = chance.guid()
29
33
  this.contactId = chance.guid()
30
34
  this.clubName = 'Test Standard Club'
@@ -122,6 +126,24 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
122
126
 
123
127
  return this
124
128
  }
129
+
130
+ withClassHashId(classHashId: typeof this.classHashId) {
131
+ this.classHashId = classHashId
132
+
133
+ return this
134
+ }
135
+
136
+ withOwner_id(owner_id: typeof this.owner_id) {
137
+ this.owner_id = owner_id
138
+
139
+ return this
140
+ }
141
+
142
+ withCurrent_media_codes(current_media_codes: typeof this.current_media_codes) {
143
+ this.current_media_codes = current_media_codes
144
+
145
+ return this
146
+ }
125
147
  }
126
148
 
127
149
  export default WidgetSettingPropsBuilder