app-tutor-ai-consumer 1.2.0 → 1.3.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 (62) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/config/vitest/__mocks__/sparkie.tsx +12 -0
  3. package/config/vitest/vitest.config.mts +1 -0
  4. package/environments/.env.test +2 -2
  5. package/package.json +2 -1
  6. package/public/assets/svg/tutor-onboarding.svg +128 -0
  7. package/src/@types/declarations.d.ts +4 -3
  8. package/src/config/optimizely/optimizely-provider.tsx +3 -3
  9. package/src/config/optimizely/optimizely.ts +1 -1
  10. package/src/config/styles/global.css +1 -0
  11. package/src/config/styles/utilities/bg-utilities.module.css +11 -0
  12. package/src/config/styles/utilities/text-utilities.module.css +6 -0
  13. package/src/config/tests/handlers.ts +3 -0
  14. package/src/lib/components/button/button.tsx +86 -0
  15. package/src/lib/components/button/index.ts +1 -0
  16. package/src/lib/components/index.ts +1 -0
  17. package/src/lib/hooks/index.ts +1 -0
  18. package/src/lib/hooks/use-ref-event-listener/index.ts +2 -0
  19. package/src/lib/hooks/use-ref-event-listener/use-ref-event-listener.tsx +32 -0
  20. package/src/main/main.spec.tsx +17 -7
  21. package/src/main/main.tsx +2 -13
  22. package/src/modules/messages/components/chat-input/chat-input.atom.ts +12 -0
  23. package/src/modules/{create-message → messages}/components/chat-input/chat-input.tsx +6 -2
  24. package/src/modules/{create-message → messages}/components/chat-input/index.ts +1 -0
  25. package/src/modules/{create-message → messages}/components/chat-input/types.ts +1 -0
  26. package/src/modules/messages/components/index.ts +2 -0
  27. package/src/modules/messages/components/messages-list/index.ts +2 -0
  28. package/src/modules/messages/components/messages-list/messages-list.tsx +24 -0
  29. package/src/modules/messages/constants.ts +1 -0
  30. package/src/modules/messages/index.ts +3 -0
  31. package/src/modules/messages/service.ts +63 -0
  32. package/src/modules/messages/types.ts +47 -0
  33. package/src/modules/sparkie/constants.ts +21 -0
  34. package/src/modules/sparkie/index.ts +3 -0
  35. package/src/modules/sparkie/service.ts +94 -0
  36. package/src/modules/sparkie/types.ts +47 -0
  37. package/src/modules/sparkie/utils/validate-firebase-config.spec.ts +17 -0
  38. package/src/modules/sparkie/utils/validate-firebase-config.ts +12 -0
  39. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +121 -0
  40. package/src/modules/widget/components/chat-page/chat-page.tsx +20 -0
  41. package/src/modules/widget/components/chat-page/index.ts +2 -0
  42. package/src/modules/widget/components/constants.tsx +9 -0
  43. package/src/modules/widget/components/container/container.tsx +32 -0
  44. package/src/modules/widget/components/container/index.ts +2 -0
  45. package/src/{main → modules/widget/components/container}/styles.module.css +1 -5
  46. package/src/modules/widget/components/container/types.ts +3 -0
  47. package/src/modules/widget/components/greetings-card/greetings-card.tsx +1 -1
  48. package/src/modules/widget/components/greetings-card/styles.module.css +1 -3
  49. package/src/modules/widget/components/index.ts +3 -0
  50. package/src/modules/widget/components/onboarding-page/index.ts +1 -0
  51. package/src/modules/widget/components/onboarding-page/onboarding-page.tsx +38 -0
  52. package/src/modules/widget/components/onboarding-page/styles.module.css +7 -0
  53. package/src/modules/widget/components/starter-page/index.ts +1 -0
  54. package/src/modules/widget/components/starter-page/starter-page.tsx +41 -0
  55. package/src/modules/widget/hooks/index.ts +1 -0
  56. package/src/modules/widget/hooks/use-init-sparkie/index.ts +1 -0
  57. package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +18 -0
  58. package/src/modules/widget/store/index.ts +1 -0
  59. package/src/modules/widget/store/widget-tabs.atom.ts +53 -0
  60. package/tailwind.config.js +95 -1
  61. package/config/vitest/index.ts +0 -1
  62. package/src/modules/create-message/components/index.ts +0 -1
@@ -13,9 +13,10 @@ declare module '*.jpg'
13
13
 
14
14
  declare module '*.gif'
15
15
 
16
- declare module '*?url'
17
-
18
- declare module '*.svg?url'
16
+ declare module '*.svg?url' {
17
+ const content: string
18
+ export default content
19
+ }
19
20
 
20
21
  declare module '*.svg' {
21
22
  const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>
@@ -8,7 +8,7 @@ import optimizelyClient from './optimizely'
8
8
 
9
9
  function OptimizelyProvider({
10
10
  settings,
11
- children,
11
+ children
12
12
  }: PropsWithChildren<{ settings: WidgetSettingProps }>) {
13
13
  const userInfo = {
14
14
  id: settings.user?.ucode ?? '',
@@ -17,8 +17,8 @@ function OptimizelyProvider({
17
17
  appSystem: APP_SYSTEM,
18
18
  appDebug: !productionMode,
19
19
  locale: settings.locale ?? '',
20
- slug: settings.membershipSlug ?? '',
21
- },
20
+ slug: settings.membershipSlug ?? ''
21
+ }
22
22
  }
23
23
 
24
24
  return (
@@ -3,7 +3,7 @@ import { createInstance } from '@optimizely/react-sdk'
3
3
  const optimizelyClient = createInstance({
4
4
  sdkKey: process.env.OPTIMIZELY_SDK_KEY,
5
5
  eventBatchSize: 10,
6
- eventFlushInterval: 1000,
6
+ eventFlushInterval: 1000
7
7
  })
8
8
 
9
9
  export default optimizelyClient
@@ -76,6 +76,7 @@
76
76
  --ai-color-primary: #a095ec;
77
77
  --ai-color-secondary: #6ba1f0;
78
78
  --ai-color-dark: #111925;
79
+ --ai-color-chat-response: #26202f;
79
80
  }
80
81
 
81
82
  #hotmart-app-tutor-ai-consumer-root {
@@ -0,0 +1,11 @@
1
+ .gradientBg {
2
+ background:
3
+ linear-gradient(
4
+ 186.9deg,
5
+ rgb(from var(--ai-color-primary) r g b / 0.1) 6.65%,
6
+ rgb(from var(--ai-color-secondary) r g b / 0.1) 28.99%,
7
+ rgb(from var(--ai-color-dark) r g b / 0.1) 46.97%,
8
+ rgb(from var(--hc-color-neutral-900) r g b / 0.8) 57.9%
9
+ ),
10
+ linear-gradient(0deg, var(--hc-color-neutral-900), var(--hc-color-neutral-900));
11
+ }
@@ -0,0 +1,6 @@
1
+ .gradientText {
2
+ background: linear-gradient(226.83deg, #7ab0ff 21.46%, #b48eff 95.76%);
3
+ background-clip: text;
4
+ -webkit-background-clip: text;
5
+ -webkit-text-fill-color: transparent;
6
+ }
@@ -3,5 +3,8 @@ import { http, HttpResponse } from 'msw'
3
3
  export const handlers = [
4
4
  http.all('https://tracking-api.buildstaging.com/rest/track/event/json/sync', () => {
5
5
  return HttpResponse.json({ ok: true })
6
+ }),
7
+ http.all('https://c3po-api-auth.buildstaging.com/v1/auth/sparkie', () => {
8
+ return HttpResponse.json({ ok: true })
6
9
  })
7
10
  ]
@@ -0,0 +1,86 @@
1
+ import clsx from 'clsx'
2
+ import type { HTMLAttributes, PropsWithChildren } from 'react'
3
+
4
+ export type ButtonProps = PropsWithChildren<
5
+ HTMLAttributes<HTMLButtonElement> & {
6
+ variant?: 'brand' | 'secondary' | 'primary' | 'tertiary' | 'gradient-outline'
7
+ }
8
+ >
9
+
10
+ function Button({ children, className, variant = 'brand', ...props }: ButtonProps) {
11
+ const defaultClasses =
12
+ 'rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 text-base font-medium border border-transparent'
13
+ const defaultPadding = 'px-4 py-2'
14
+
15
+ switch (variant) {
16
+ case 'gradient-outline':
17
+ return (
18
+ <button
19
+ className={clsx(
20
+ defaultClasses,
21
+ 'group relative inline-flex items-center justify-center overflow-hidden bg-gradient-to-br from-purple-600 to-blue-500 p-0.5 hover:text-neutral-0 group-hover:from-purple-600 group-hover:to-blue-500',
22
+ className
23
+ )}
24
+ {...props}>
25
+ <span className='relative flex-1 rounded-md bg-neutral-900 px-5 py-2.5 text-neutral-0 transition-all duration-75 ease-in group-hover:bg-transparent'>
26
+ {children}
27
+ </span>
28
+ </button>
29
+ )
30
+ case 'primary':
31
+ return (
32
+ <button
33
+ className={clsx(
34
+ defaultPadding,
35
+ defaultClasses,
36
+ 'bg-primary-500 text-neutral-0 hover:bg-primary-600 focus:outline-none',
37
+ className
38
+ )}
39
+ {...props}>
40
+ {children}
41
+ </button>
42
+ )
43
+ case 'secondary':
44
+ return (
45
+ <button
46
+ className={clsx(
47
+ defaultPadding,
48
+ defaultClasses,
49
+ 'border-neutral-100 bg-transparent text-neutral-100 hover:bg-neutral-100 hover:text-neutral-900',
50
+ className
51
+ )}
52
+ {...props}>
53
+ {children}
54
+ </button>
55
+ )
56
+ case 'tertiary':
57
+ return (
58
+ <button
59
+ className={clsx(
60
+ defaultPadding,
61
+ defaultClasses,
62
+ 'bg-transparent text-neutral-0 hover:bg-neutral-900',
63
+ className
64
+ )}
65
+ {...props}>
66
+ {children}
67
+ </button>
68
+ )
69
+ case 'brand':
70
+ default:
71
+ return (
72
+ <button
73
+ className={clsx(
74
+ defaultPadding,
75
+ defaultClasses,
76
+ 'rounded bg-neutral-100 text-neutral-900 outline-none hover:bg-neutral-200',
77
+ className
78
+ )}
79
+ {...props}>
80
+ {children}
81
+ </button>
82
+ )
83
+ }
84
+ }
85
+
86
+ export default Button
@@ -0,0 +1 @@
1
+ export { default as Button } from './button'
@@ -1,3 +1,4 @@
1
+ export * from './button'
1
2
  export * from './errors'
2
3
  export * from './icons'
3
4
  export * from './spinner'
@@ -1 +1,2 @@
1
1
  export * from './use-default-id'
2
+ export * from './use-ref-event-listener'
@@ -0,0 +1,2 @@
1
+ export * from './use-ref-event-listener'
2
+ export { default as useRefEventListener } from './use-ref-event-listener'
@@ -0,0 +1,32 @@
1
+ import { useEffect } from 'react'
2
+
3
+ export type UseRefEventListenerConfig<T extends HTMLElement> = {
4
+ config: {
5
+ ref: React.RefObject<T | null>
6
+ eventTypes: string[]
7
+ handler: (event: Event) => void
8
+ options?: AddEventListenerOptions
9
+ }
10
+ }
11
+
12
+ function useRefEventListener<T extends HTMLElement>({
13
+ config: { eventTypes, handler, ref, options }
14
+ }: UseRefEventListenerConfig<T>) {
15
+ useEffect(() => {
16
+ const element = ref.current
17
+
18
+ if (!element) return
19
+
20
+ eventTypes.forEach((ev) => {
21
+ element.addEventListener(ev, handler, options)
22
+ })
23
+
24
+ return () => {
25
+ eventTypes.forEach((ev) => {
26
+ element.removeEventListener(ev, handler, options)
27
+ })
28
+ }
29
+ }, [ref, eventTypes, handler, options])
30
+ }
31
+
32
+ export default useRefEventListener
@@ -1,15 +1,25 @@
1
- import { render, screen, waitFor } from '@/config/tests'
2
- import type { WidgetSettingProps } from '../types'
1
+ import { chance, render, screen, waitFor } from '@/config/tests'
2
+ import WidgetSettingPropsBuilder from '../modules/widget/__tests__/widget-settings-props.builder'
3
3
  import { Main } from '.'
4
4
 
5
5
  describe('Main', () => {
6
- const renderComponent = () => render(<Main settings={{} as WidgetSettingProps} />)
6
+ const defaultProps = new WidgetSettingPropsBuilder()
7
+ const renderComponent = (props = { settings: defaultProps }) => render(<Main {...props} />)
8
+
9
+ it('should render empty element when settings.tutorName is not defined', async () => {
10
+ const { container } = renderComponent()
11
+
12
+ await waitFor(() => {
13
+ expect(container).toBeEmptyDOMElement()
14
+ })
15
+ })
7
16
 
8
17
  it('should render without errors', async () => {
9
- renderComponent()
18
+ const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
19
+ renderComponent({ settings: props })
10
20
 
11
- await waitFor(() =>
12
- expect(screen.getByText(/general.greetings.description/i)).toBeInTheDocument()
13
- )
21
+ await waitFor(() => {
22
+ expect(screen.getByText(/onboarding.description/i)).toBeInTheDocument()
23
+ })
14
24
  })
15
25
  })
package/src/main/main.tsx CHANGED
@@ -1,17 +1,12 @@
1
1
  import '@/config/styles/index.css'
2
2
 
3
- import clsx from 'clsx'
4
-
5
3
  import { ErrorBoundary, GenericError } from '@/src/lib/components/errors'
6
4
  import { useDefaultId } from '@/src/lib/hooks'
7
5
  import { useAppLang } from '../config/i18n'
8
- import { ChatInput } from '../modules/create-message/components'
9
6
  import { GlobalProviders } from '../modules/global-providers'
10
- import { GreetingsCard } from '../modules/widget/components'
7
+ import { WidgetContainer } from '../modules/widget'
11
8
  import type { WidgetSettingProps } from '../types'
12
9
 
13
- import styles from './styles.module.css'
14
-
15
10
  function Main({ settings }: { settings: WidgetSettingProps }) {
16
11
  useDefaultId()
17
12
  useAppLang(settings.locale)
@@ -19,13 +14,7 @@ function Main({ settings }: { settings: WidgetSettingProps }) {
19
14
  return (
20
15
  <ErrorBoundary fallback={<GenericError />}>
21
16
  <GlobalProviders settings={settings}>
22
- <div
23
- className={clsx('flex min-h-svh flex-col items-center justify-center p-5', styles.main)}>
24
- <div className='flex flex-1 flex-col justify-center gap-6 lg:max-w-sm'>
25
- <GreetingsCard author={settings.author ?? ''} tutorName={settings.tutorName ?? ''} />
26
- <ChatInput name='new-chat-msg-input' />
27
- </div>
28
- </div>
17
+ <WidgetContainer />
29
18
  </GlobalProviders>
30
19
  </ErrorBoundary>
31
20
  )
@@ -0,0 +1,12 @@
1
+ import { atom, useAtom } from 'jotai'
2
+
3
+ const chatInputValueAtom = atom<string>('')
4
+
5
+ export const chatInputAtom = atom(
6
+ (get) => get(chatInputValueAtom),
7
+ (_, set, config: string) => {
8
+ set(chatInputValueAtom, config)
9
+ }
10
+ )
11
+
12
+ export const useChatInputValueAtom = () => useAtom(chatInputAtom)
@@ -3,10 +3,13 @@ import { useTranslation } from 'react-i18next'
3
3
 
4
4
  import { Icon } from '@/src/lib/components'
5
5
 
6
+ import { useChatInputValueAtom } from './chat-input.atom'
6
7
  import type { ChatInputProps } from './types'
7
8
 
8
- const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name }, ref) {
9
+ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name, onSend }, ref) {
9
10
  const { t } = useTranslation()
11
+ const [value] = useChatInputValueAtom()
12
+
10
13
  return (
11
14
  <div className='flex items-center rounded-full border-neutral-800 bg-neutral-800 px-4 py-2'>
12
15
  <input
@@ -16,8 +19,9 @@ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name
16
19
  type='text'
17
20
  className='h-6 w-full border-none bg-transparent text-neutral-400 outline-0 placeholder:text-neutral-400'
18
21
  placeholder={t('send_message.field.placeholder')}
22
+ defaultValue={value}
19
23
  />
20
- <button>
24
+ <button onClick={onSend}>
21
25
  <Icon name='send' className='h-4 w-4 text-neutral-50' />
22
26
  </button>
23
27
  </div>
@@ -1,2 +1,3 @@
1
1
  export { default as ChatInput } from './chat-input'
2
+ export * from './chat-input.atom'
2
3
  export * from './types'
@@ -1,3 +1,4 @@
1
1
  export type ChatInputProps = {
2
2
  name: string
3
+ onSend?: () => void
3
4
  }
@@ -0,0 +1,2 @@
1
+ export * from './chat-input'
2
+ export * from './messages-list'
@@ -0,0 +1,2 @@
1
+ export * from './messages-list'
2
+ export { default as MessagesList } from './messages-list'
@@ -0,0 +1,24 @@
1
+ function MessagesList() {
2
+ return (
3
+ <div className='flex flex-1 flex-col items-start justify-center gap-6'>
4
+ <div className='max-w-[80%] self-end rounded-lg bg-neutral-800 p-3 text-sm/normal text-neutral-0'>
5
+ <span>Quero saber o que Tutor do BEM faz</span>
6
+ </div>
7
+ <div className='flex max-w-[80%] flex-col gap-1 rounded-lg bg-ai-chat-response p-3 text-sm/normal text-neutral-0'>
8
+ <span>
9
+ Sou seu assistente de IA, criado pelo Marcelo Horta, para te acompanhar durante todo o seu
10
+ aprendizado. Confira o que posso fazer por você:
11
+ </span>
12
+ <span>
13
+ 🤓 Dúvidas sobre as aulas:Me faça perguntas sobre o conteúdo das aulas e eu ajudo você a
14
+ aprender melhor.
15
+ </span>
16
+ <span>
17
+ 📝 ResumosPosso gerar um resumo do que foi visto na aula pra facilitar seus estudos.
18
+ </span>
19
+ </div>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export default MessagesList
@@ -0,0 +1 @@
1
+ export const MSG_MAX_COUNT = 20
@@ -0,0 +1,3 @@
1
+ export * from './constants'
2
+ export { default as MessagesService } from './service'
3
+ export * from './types'
@@ -0,0 +1,63 @@
1
+ import type { Message, MessageWithSenderData } from '@hotmart/sparkie/dist/MessageService'
2
+
3
+ import { ApiError } from '@/src/config/request'
4
+ import { HttpCodes } from '@/src/lib/utils'
5
+ import { SparkieService } from '../sparkie'
6
+
7
+ import type {
8
+ IGetMessagesPayload,
9
+ ISendImageMessagePayload,
10
+ ISendTextMessagePayload
11
+ } from './types'
12
+
13
+ class MessagesService {
14
+ private get sparkieMessageService() {
15
+ try {
16
+ const messageService = SparkieService.sparkieInstance.messageService
17
+
18
+ if (!messageService) throw new Error()
19
+
20
+ return messageService
21
+ } catch (error) {
22
+ throw new ApiError({
23
+ statusCode: HttpCodes.UNPROCESSABLE_ENTITY,
24
+ message: 'sparkie.messageService not defined',
25
+ extra: { error }
26
+ })
27
+ }
28
+ }
29
+
30
+ async getMessages({
31
+ conversationId,
32
+ before
33
+ }: IGetMessagesPayload): Promise<Array<MessageWithSenderData>> {
34
+ const data = await this.sparkieMessageService.getAll(conversationId, {
35
+ before
36
+ })
37
+
38
+ return data ?? []
39
+ }
40
+
41
+ async sendTextMessage({
42
+ content,
43
+ conversationId,
44
+ metadata
45
+ }: ISendTextMessagePayload): Promise<Message> {
46
+ const data = await this.sparkieMessageService.post(conversationId, {
47
+ content,
48
+ metadata
49
+ })
50
+
51
+ return data
52
+ }
53
+
54
+ async sendImageMessage({ content, conversationId }: ISendImageMessagePayload): Promise<Message> {
55
+ const data = await this.sparkieMessageService.post(conversationId, {
56
+ content
57
+ })
58
+
59
+ return data
60
+ }
61
+ }
62
+
63
+ export default new MessagesService()
@@ -0,0 +1,47 @@
1
+ import type { Message } from '@hotmart/sparkie/dist/MessageService'
2
+
3
+ export type IMessage = Message & {
4
+ sending?: boolean // indicates when the current message is being sent
5
+ }
6
+
7
+ export type IGetMessagesPayload = {
8
+ conversationId: string
9
+ before?: number
10
+ }
11
+
12
+ export type ISendTextMessagePayload = {
13
+ conversationId: string
14
+ content: {
15
+ type: string
16
+ text: string
17
+ }
18
+ metadata: {
19
+ [k: string]: string | undefined | number | { [k: string]: string | undefined | number }
20
+ }
21
+ }
22
+
23
+ export type ISendImageMessagePayload = {
24
+ conversationId: string
25
+ content: {
26
+ type: string
27
+ name: string
28
+ dimensions: IDimensions
29
+ thumbnails: {
30
+ lg: string
31
+ md: string
32
+ sm: string
33
+ }
34
+ url: string
35
+ }
36
+ }
37
+
38
+ export type IDimensions = {
39
+ width: number
40
+ height: number
41
+ }
42
+
43
+ export type IFetchMessagesOptions = {
44
+ currentMessages: Array<IMessage>
45
+ conversationId: string
46
+ loadFirstPage?: boolean
47
+ }
@@ -0,0 +1,21 @@
1
+ export const firebaseConfig = {
2
+ apiKey: process.env.FIREBASE_API_KEY,
3
+ authDomain: process.env.FIREBASE_AUTH_DOMAIN,
4
+ databaseURL: process.env.FIREBASE_DATABASE_URL,
5
+ projectId: process.env.FIREBASE_PROJECT_ID,
6
+ storageBucket: process.env.FIREBASE_STORAGE_BUCKET,
7
+ messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID
8
+ }
9
+
10
+ export const firebaseConfigRequiredFields = new Set(Object.keys(firebaseConfig))
11
+
12
+ export const ACTION_EVENT_MAP = {
13
+ conversationAdded: 'conversations.create',
14
+ conversationUpdated: 'conversations.update',
15
+ cursorsUpdate: 'cursors.update',
16
+ messageReceived: 'messages.create',
17
+ messageDeleted: 'messages.delete',
18
+ threadClosed: 'threads.close',
19
+ threadJoined: 'threads.join',
20
+ contactTyping: 'contacts.typing'
21
+ }
@@ -0,0 +1,3 @@
1
+ export * from './constants'
2
+ export { default as SparkieService } from './service'
3
+ export * from './types'
@@ -0,0 +1,94 @@
1
+ import Sparkie from '@hotmart/sparkie'
2
+
3
+ import { MSG_MAX_COUNT } from '../messages'
4
+
5
+ import { ACTION_EVENT_MAP, firebaseConfig } from './constants'
6
+ import type { InitSparkiePayload, SparkieActions, TrackTypingPayload } from './types'
7
+ import { validateFirebaseConfig } from './utils/validate-firebase-config'
8
+
9
+ class SparkieService {
10
+ private sparkie: Sparkie | null = null
11
+
12
+ constructor() {
13
+ if (validateFirebaseConfig()) {
14
+ this.sparkie = new Sparkie({
15
+ firebaseConfig,
16
+ options: {
17
+ conversationAPI: process.env.API_CONVERSATION_URL,
18
+ authURL: process.env.API_AUTH_URL,
19
+ numMaxMessages: MSG_MAX_COUNT
20
+ }
21
+ })
22
+ }
23
+ }
24
+
25
+ get sparkieInstance(): Sparkie {
26
+ if (!this.sparkie)
27
+ throw new Error('Sparkie instance not available. Check Firebase configuration.')
28
+
29
+ return this.sparkie
30
+ }
31
+
32
+ async initSparkie({ token, skipPresenceSetup = false }: InitSparkiePayload): Promise<boolean> {
33
+ const sparkie = this.sparkieInstance
34
+
35
+ try {
36
+ if (!token || !token.trim())
37
+ throw new Error('Invalid or missing token for Sparkie initialization')
38
+
39
+ await sparkie.init(token, { skipPresenceSetup })
40
+ 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
+ })
51
+ }
52
+
53
+ return false
54
+ }
55
+ }
56
+
57
+ updateToken(token: string): void {
58
+ this.sparkieInstance.setAPIToken(token)
59
+ }
60
+
61
+ removeEventSubscription(actions: SparkieActions): void {
62
+ Object.entries(actions).forEach(([actionName, listener]) => {
63
+ this.sparkieInstance.off(
64
+ ACTION_EVENT_MAP[actionName as keyof typeof ACTION_EVENT_MAP],
65
+ listener as (data: Record<string, unknown>) => void
66
+ )
67
+ })
68
+ }
69
+
70
+ subscribeEvents({
71
+ messageReceived,
72
+ threadClosed,
73
+ threadJoined,
74
+ contactTyping
75
+ }: SparkieActions): void {
76
+ if (messageReceived) this.sparkieInstance.on(ACTION_EVENT_MAP.messageReceived, messageReceived)
77
+
78
+ if (threadClosed) this.sparkieInstance.on(ACTION_EVENT_MAP.threadClosed, threadClosed)
79
+
80
+ if (threadJoined) this.sparkieInstance.on(ACTION_EVENT_MAP.threadJoined, threadJoined)
81
+
82
+ if (contactTyping) this.sparkieInstance.on(ACTION_EVENT_MAP.contactTyping, contactTyping)
83
+ }
84
+
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 })
91
+ }
92
+ }
93
+
94
+ export default new SparkieService()
@@ -0,0 +1,47 @@
1
+ import type { IMessage } from '../messages'
2
+
3
+ export type InitSparkiePayload = {
4
+ token: string
5
+ webinarConversationId?: string
6
+ skipPresenceSetup?: boolean
7
+ includeSupportEvents?: boolean
8
+ }
9
+
10
+ type ThreadMetadata = Record<string, unknown>
11
+
12
+ export type IContact = {
13
+ id: string
14
+ name: string
15
+ picture: string
16
+ userId: string | number
17
+ }
18
+
19
+ export type IThread = {
20
+ id: string
21
+ conversationId: string
22
+ contact: IContact
23
+ startedAt: number
24
+ subject?: string
25
+ metadata?: ThreadMetadata
26
+ endedAt?: number
27
+ updatedAt?: number
28
+ deletedAt?: number
29
+ }
30
+
31
+ export type ITyping = {
32
+ contactId: string
33
+ conversationId: string
34
+ status: 'typing' | 'idle'
35
+ }
36
+
37
+ export type SparkieActions = {
38
+ messageReceived?: (data: IMessage) => void
39
+ threadClosed?: (data: IThread) => void
40
+ threadJoined?: (data: IThread) => void
41
+ contactTyping?: (data: ITyping) => void
42
+ }
43
+
44
+ export type TrackTypingPayload = {
45
+ conversationId: string
46
+ contactIds: Array<string>
47
+ }