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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ # [1.7.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.6.0...v1.7.0) (2025-07-11)
2
+
3
+ ### Features
4
+
5
+ - add send text message validation ([4f242ca](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/4f242caf92ec3cdab2ae5866d67abd6f1b5d864f))
6
+
7
+ # [1.6.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.5.0...v1.6.0) (2025-07-11)
8
+
9
+ ### Features
10
+
11
+ - add send text message support ([6526d55](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/6526d55fe2781cd9842be805f3406bf2176c825e))
12
+
1
13
  # [1.5.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.4.0...v1.5.0) (2025-07-10)
2
14
 
3
15
  ### Bug Fixes
@@ -1,3 +1,12 @@
1
1
  import SparkieMock from '@/src/modules/sparkie/__tests__/sparkie.mock'
2
+ import { SparkieService } from '@/src/modules/sparkie'
3
+ import { SparkieMessageServiceMock } from '@/src/modules/sparkie/__tests__/sparkie.mock'
4
+ import MessageService from '@hotmart/sparkie/dist/MessageService'
2
5
 
3
6
  vi.mock('@hotmart/sparkie', () => ({ default: SparkieMock }))
7
+
8
+ beforeEach(() => {
9
+ vi.spyOn(SparkieService, 'getMessageService').mockResolvedValue(
10
+ SparkieMessageServiceMock as unknown as MessageService
11
+ )
12
+ })
package/eslint.config.mjs CHANGED
@@ -8,6 +8,7 @@ import globals from 'globals'
8
8
  import simpleImportSort from 'eslint-plugin-simple-import-sort'
9
9
  import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
10
10
  import pluginQuery from '@tanstack/eslint-plugin-query'
11
+ import vitest from '@vitest/eslint-plugin'
11
12
 
12
13
  export default tseslint.config(
13
14
  {
@@ -89,4 +90,30 @@ export default tseslint.config(
89
90
  '@typescript-eslint/no-explicit-any': 'off',
90
91
  },
91
92
  },
93
+ {
94
+ files: [
95
+ '**/*.test.ts',
96
+ '**/*.test.tsx',
97
+ '**/*.spec.ts',
98
+ '**/*.spec.tsx'
99
+ ],
100
+ plugins: {
101
+ vitest,
102
+ },
103
+ languageOptions: {
104
+ globals: {
105
+ ...vitest.environments.env.globals,
106
+ },
107
+ },
108
+ rules: {
109
+ ...vitest.configs.recommended.rules,
110
+ '@typescript-eslint/unbound-method': 'off',
111
+ '@typescript-eslint/no-unsafe-assignment': 'off',
112
+ '@typescript-eslint/no-unsafe-member-access': 'off',
113
+ '@typescript-eslint/no-unsafe-call': 'off',
114
+ '@typescript-eslint/no-unsafe-return': 'off',
115
+ '@typescript-eslint/no-explicit-any': 'off',
116
+ '@typescript-eslint/no-non-null-assertion': 'off',
117
+ },
118
+ }
92
119
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-tutor-ai-consumer",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "dev": "rspack serve --env=development --config config/rspack/rspack.config.js",
@@ -60,9 +60,11 @@
60
60
  "@types/react-dom": "~19.1.6",
61
61
  "@types/react-router-dom": "~5.3.3",
62
62
  "@types/ua-parser-js": "~0.7.39",
63
+ "@types/uuid": "~10.0.0",
63
64
  "@vitejs/plugin-react": "~4.5.2",
64
65
  "@vitest/coverage-istanbul": "~3.2.3",
65
66
  "@vitest/coverage-v8": "~3.2.3",
67
+ "@vitest/eslint-plugin": "~1.3.4",
66
68
  "autoprefixer": "~10.4.21",
67
69
  "chance": "~1.1.13",
68
70
  "compression-webpack-plugin": "~11.1.0",
@@ -111,6 +113,7 @@
111
113
  "dayjs": "~1.11.13",
112
114
  "i18next": "~25.2.1",
113
115
  "i18next-resources-to-backend": "~1.2.1",
116
+ "immer": "~10.1.1",
114
117
  "jotai": "~2.12.5",
115
118
  "linkify-it": "~5.0.0",
116
119
  "prism-react-renderer": "~2.4.1",
@@ -118,10 +121,12 @@
118
121
  "react-dom": "~19.1.0",
119
122
  "react-i18next": "~15.5.2",
120
123
  "react-markdown": "~10.1.0",
124
+ "react-textarea-autosize": "~8.5.9",
121
125
  "rehype-raw": "~7.0.0",
122
126
  "rehype-sanitize": "~6.0.0",
123
127
  "remark-breaks": "~4.0.0",
124
- "remark-gfm": "~4.0.1"
128
+ "remark-gfm": "~4.0.1",
129
+ "uuid": "~11.1.0"
125
130
  },
126
131
  "optionalDependencies": {
127
132
  "@rollup/rollup-linux-x64-gnu": "4.6.1",
@@ -9,8 +9,11 @@ declare global {
9
9
 
10
10
  interface Window {
11
11
  __INJECT_CSS_MODULE__?: (cssText: string, id: string) => void
12
- startTutorWidget: (props: StartTutorWidgetProps) => Promise<void>
13
- closeTutorWidget: () => void
12
+ startChatWidget: (
13
+ elementId: StartTutorWidgetProps['elementId'],
14
+ settings: StartTutorWidgetProps['settings']
15
+ ) => Promise<void>
16
+ closeChatWidget: () => void
14
17
  TOKEN: string
15
18
  }
16
19
  }
@@ -79,14 +79,15 @@
79
79
  --ai-color-chat-response: #1e1926;
80
80
 
81
81
  /* Size */
82
+ --hc-size-spacing-1: 0.25rem;
82
83
  --hc-size-spacing-2: 0.5rem;
83
84
  --hc-size-border-medium: 0.5rem;
84
85
  }
85
86
 
86
87
  #hotmart-app-tutor-ai-consumer-root {
87
88
  & *::-webkit-scrollbar {
88
- width: var(--hc-size-spacing-2);
89
- height: var(--hc-size-spacing-2);
89
+ width: var(--custom-scrollbar-width, var(--hc-size-spacing-2));
90
+ height: var(--custom-scrollbar-width, var(--hc-size-spacing-2));
90
91
  }
91
92
 
92
93
  & *::-webkit-scrollbar-track {
@@ -14,7 +14,7 @@ export const queryClient = new QueryClient({
14
14
  retry(failureCount, error) {
15
15
  if (!api?.defaults?.headers?.common?.['Authorization']) return false
16
16
 
17
- const maxRetries = 2
17
+ const maxRetries = 3
18
18
  const statusCode = (error as ApiError).statusCode
19
19
 
20
20
  return (
@@ -1,26 +1,26 @@
1
1
  import './index'
2
2
 
3
+ import { v4 } from 'uuid'
4
+
3
5
  import { LANGUAGES } from './config/i18n'
4
6
  import { devMode } from './lib/utils'
5
7
 
6
8
  if (devMode) {
7
9
  window.TOKEN = process.env.TOKEN ?? ''
8
10
  void (async () => {
9
- await window.startTutorWidget({
10
- elementId: 'root',
11
- settings: {
12
- hotmartToken: window.TOKEN,
13
- locale: LANGUAGES.PT_BR,
14
- conversationId: '21506473-a93c-4b38-9c32-68a5ca37ce73', // OWNER
15
- tutorName: 'Prof Jou Robots',
16
- contactId: '38138170-6009-40cd-be50-001249e80a0d',
17
- membershipId: '6297a4efa488cc775ac5e1dd',
18
- namespace: 'tutor_v1-2',
19
- author: 'Jonathan',
20
- clubName: 'comofazerumvideodeteste',
21
- productName: 'Curso de Assinatura',
22
- productId: 4266504
23
- }
11
+ await window.startChatWidget('root', {
12
+ hotmartToken: window.TOKEN,
13
+ locale: LANGUAGES.PT_BR,
14
+ conversationId: '21506473-a93c-4b38-9c32-68a5ca37ce73', // OWNER
15
+ tutorName: 'Prof Jou Robots',
16
+ contactId: '38138170-6009-40cd-be50-001249e80a0d',
17
+ membershipId: '6297a4efa488cc775ac5e1dd',
18
+ namespace: 'tutor_v1-2',
19
+ author: 'user',
20
+ clubName: 'comofazerumvideodeteste',
21
+ productName: 'Curso de Assinatura',
22
+ productId: 4266504,
23
+ sessionId: v4()
24
24
  })
25
25
  })()
26
26
  }
package/src/index.tsx CHANGED
@@ -9,8 +9,9 @@ import { initLanguage } from './config/i18n'
9
9
  import { initAxios } from './config/request/api'
10
10
  import { productionMode } from './lib/utils'
11
11
  import { Main } from './main'
12
+ import { SparkieService } from './modules/sparkie'
12
13
  import { TutorWidgetEvents, TutorWidgetEventTypes } from './modules/widget'
13
- import type { StartTutorWidgetProps } from './types'
14
+ import type { WidgetSettingProps } from './types'
14
15
 
15
16
  const loadMainStyles = () => {
16
17
  const isProduction = productionMode
@@ -28,15 +29,24 @@ const loadMainStyles = () => {
28
29
  }
29
30
  }
30
31
 
31
- window.startTutorWidget = async ({
32
+ window.startChatWidget = async (
32
33
  elementId = 'tutor-chat-app-widget',
33
- settings
34
- }: StartTutorWidgetProps) => {
34
+ settings: WidgetSettingProps
35
+ ) => {
35
36
  loadMainStyles()
36
37
 
37
38
  const rootElement = document.getElementById(elementId) as HTMLElement
38
39
  const root = createRoot(rootElement)
39
40
 
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
+ })
40
50
  initAxios(settings.hotmartToken)
41
51
  await initLanguage(settings.locale)
42
52
  await initDayjs(settings.locale)
@@ -49,4 +59,4 @@ window.startTutorWidget = async ({
49
59
  )
50
60
  }
51
61
 
52
- window.closeTutorWidget = () => TutorWidgetEvents.get(TutorWidgetEventTypes.CLOSE)?.dispatch()
62
+ window.closeChatWidget = () => TutorWidgetEvents.get(TutorWidgetEventTypes.CLOSE)?.dispatch()
@@ -0,0 +1,17 @@
1
+ <svg viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M21.5813 16.7843L24.9086 18.5499L21.5813 20.3154L19.8068 23.6263L18.0313 20.3154L14.7041 18.5499L18.0313 16.7843L19.8068 13.4734L21.5813 16.7843ZM11.9119 9.10141L17.2354 11.9261L11.9119 14.7508L9.07199 20.0485L6.23301 14.7508L0.908569 11.9261L6.23301 9.10141L9.07199 3.80376L11.9119 9.10141ZM20.1457 3.24642L22.7786 4.64367L20.1457 6.03994L18.7413 8.66002L17.337 6.03994L14.7041 4.64367L17.337 3.24642L18.7413 0.626343L20.1457 3.24642Z"
4
+ fill="url(#paint0_linear_18592_52336)" />
5
+ <defs>
6
+ <linearGradient
7
+ id="paint0_linear_18592_52336"
8
+ x1="0.908569"
9
+ y1="12.1263"
10
+ x2="24.9086"
11
+ y2="12.1263"
12
+ gradientUnits="userSpaceOnUse">
13
+ <stop stop-color="#44D0FF" />
14
+ <stop offset="1" stop-color="#B48EFF" />
15
+ </linearGradient>
16
+ </defs>
17
+ </svg>
@@ -1,2 +1,2 @@
1
1
  // Auto-generated file - DO NOT EDIT
2
- export type ValidIconNames = 'arrow-down' | 'chevron-down' | 'send'
2
+ export type ValidIconNames = 'ai-color' | 'arrow-down' | 'chevron-down' | 'send' | 'stop'
@@ -0,0 +1,4 @@
1
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
2
+ >
3
+ <rect x="7" y="7" width="10" height="10" rx="1.25" fill="currentColor"></rect>
4
+ </svg>
@@ -0,0 +1,3 @@
1
+ export function isTextEmpty(text: string) {
2
+ return /\S/.test(text)
3
+ }
@@ -13,14 +13,6 @@ describe('Main', () => {
13
13
  vi.mocked(useWidgetSettingsAtom).mockReturnValue([null, vi.fn()])
14
14
  })
15
15
 
16
- it('should render empty element when settings.tutorName is not defined', async () => {
17
- const { container } = renderComponent()
18
-
19
- await waitFor(() => {
20
- expect(container).toBeEmptyDOMElement()
21
- })
22
- })
23
-
24
16
  it('should render without errors', async () => {
25
17
  const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
26
18
  vi.mocked(useWidgetSettingsAtom).mockReturnValue([props, vi.fn()])
@@ -0,0 +1,76 @@
1
+ import { createRef } from 'react'
2
+
3
+ import { chance, fireEvent, render, screen } from '@/src/config/tests'
4
+
5
+ import ChatInput from './chat-input'
6
+ import { useChatInputValueAtom } from './chat-input.atom'
7
+
8
+ vi.mock('./chat-input.atom', () => ({ useChatInputValueAtom: vi.fn() }))
9
+
10
+ describe('ChatInput', () => {
11
+ const ref = createRef<HTMLTextAreaElement>()
12
+ const chatInputValueAtomMock = { val: '', setVal: vi.fn() }
13
+ const defaultProps = { name: chance.name() }
14
+
15
+ const renderComponent = (props = defaultProps) => render(<ChatInput {...props} ref={ref} />)
16
+
17
+ beforeEach(() => {
18
+ vi.mocked(useChatInputValueAtom).mockReturnValue([
19
+ chatInputValueAtomMock.val,
20
+ chatInputValueAtomMock.setVal
21
+ ])
22
+ })
23
+
24
+ it('should call focus when rendering the input', () => {
25
+ renderComponent()
26
+
27
+ expect(ref.current).toHaveFocus()
28
+ })
29
+
30
+ it('should call setValue when ref change event is called', () => {
31
+ renderComponent()
32
+
33
+ expect(ref.current).not.toBeNull()
34
+
35
+ const event = { target: { value: 'Test message' } }
36
+
37
+ fireEvent.change(ref.current!, event)
38
+
39
+ expect(chatInputValueAtomMock.setVal).toHaveBeenCalledTimes(1)
40
+ expect(chatInputValueAtomMock.setVal).toHaveBeenNthCalledWith(1, event.target.value)
41
+ })
42
+
43
+ it('should call onSend prop when it is defined and user presses the EnterKey', () => {
44
+ const onSend = vi.fn()
45
+ renderComponent({ ...defaultProps, onSend } as never)
46
+
47
+ expect(ref.current).not.toBeNull()
48
+
49
+ const event = { code: 'Enter' }
50
+
51
+ fireEvent.keyDown(ref.current!, event)
52
+
53
+ expect(onSend).toHaveBeenCalledTimes(1)
54
+ })
55
+
56
+ it('should disable the button when buttonDisabled prop is true', () => {
57
+ renderComponent({ buttonDisabled: true } as never)
58
+
59
+ expect(screen.getByRole('button', { name: /Submit Button/i })).toBeDisabled()
60
+ })
61
+
62
+ it('should enable the button when buttonDisabled prop is falsy', () => {
63
+ renderComponent()
64
+
65
+ expect(screen.getByRole('button', { name: /Submit Button/i })).toBeEnabled()
66
+ })
67
+
68
+ it('should set textarea value correctly', () => {
69
+ const name = chance.name()
70
+ vi.mocked(useChatInputValueAtom).mockReturnValue([name, vi.fn()])
71
+
72
+ renderComponent()
73
+
74
+ expect(ref.current?.value).toBe(name)
75
+ })
76
+ })
@@ -1,32 +1,109 @@
1
- import { forwardRef } from 'react'
1
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
2
+ import clsx from 'clsx'
3
+ import type { ChangeEvent, KeyboardEvent } from 'react'
2
4
  import { useTranslation } from 'react-i18next'
5
+ import TextareaAutosize from 'react-textarea-autosize'
3
6
 
4
- import { Icon } from '@/src/lib/components'
7
+ import { Icon, Spinner } from '@/src/lib/components'
5
8
 
6
9
  import { useChatInputValueAtom } from './chat-input.atom'
7
10
  import type { ChatInputProps } from './types'
8
11
 
9
- const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name, onSend }, ref) {
10
- const { t } = useTranslation()
11
- const [value] = useChatInputValueAtom()
12
-
13
- return (
14
- <div className='flex items-center rounded-full border-neutral-800 bg-neutral-800 px-4 py-2'>
15
- <input
16
- id={name}
17
- name={name}
18
- ref={ref}
19
- type='text'
20
- className='h-6 w-full border-none bg-transparent text-neutral-400 outline-0 placeholder:text-neutral-400'
21
- placeholder={t('send_message.field.placeholder')}
22
- defaultValue={value}
23
- />
24
- <button onClick={onSend}>
25
- <Icon name='send' className='h-4 w-4 text-neutral-50' />
26
- </button>
27
- </div>
28
- )
29
- })
12
+ import styles from './styles.module.css'
13
+
14
+ const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
15
+ (
16
+ { name, onSend, loading = false, inputDisabled = false, buttonDisabled = false },
17
+ forwardedRef
18
+ ) => {
19
+ const { t } = useTranslation()
20
+ const [value, setValue] = useChatInputValueAtom()
21
+ const ref = useRef<HTMLTextAreaElement>(null)
22
+
23
+ useImperativeHandle(forwardedRef, () => ref?.current as HTMLTextAreaElement)
24
+
25
+ const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
26
+ if (inputDisabled) return
27
+
28
+ setValue(e.target.value)
29
+ }
30
+
31
+ const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
32
+ if (loading || buttonDisabled) return
33
+
34
+ const isEnterKey = e.code === 'Enter'
35
+ const isShiftKey = e.shiftKey
36
+
37
+ if (isEnterKey && !isShiftKey) {
38
+ e.preventDefault()
39
+ onSend?.()
40
+ }
41
+ }
42
+
43
+ useEffect(() => {
44
+ if (inputDisabled) return
45
+
46
+ const input = ref?.current
47
+
48
+ if (input) {
49
+ input.focus()
50
+
51
+ const position = input.textLength ?? 0
52
+ input.setSelectionRange(position, position)
53
+ }
54
+ }, [inputDisabled])
55
+
56
+ return (
57
+ <div
58
+ className={clsx(
59
+ 'flex items-center rounded-full border border-neutral-800 bg-neutral-900 px-4 py-2',
60
+ { 'cursor-not-allowed opacity-40': inputDisabled }
61
+ )}>
62
+ <TextareaAutosize
63
+ id={name}
64
+ name={name}
65
+ ref={ref}
66
+ className={clsx(
67
+ clsx(
68
+ 'max-h-12 w-full resize-none border-none bg-transparent text-neutral-100 outline-none outline-0 placeholder:text-neutral-400',
69
+ styles.textArea
70
+ ),
71
+ { 'cursor-not-allowed': inputDisabled, 'opacity-40': inputDisabled || loading }
72
+ )}
73
+ placeholder={t('send_message.field.placeholder')}
74
+ value={value}
75
+ onChange={handleChange}
76
+ onKeyDown={handleKeyDown}
77
+ disabled={inputDisabled}
78
+ />
79
+ <button
80
+ onClick={onSend}
81
+ disabled={buttonDisabled || loading}
82
+ className={clsx(
83
+ 'flex size-8 flex-col items-center justify-center rounded-full outline-none transition-colors duration-300 ease-in',
84
+ {
85
+ 'cursor-pointer hover:scale-110 hover:bg-neutral-600 focus:bg-neutral-700 focus:outline-none focus:ring-1 focus:ring-neutral-500 focus:ring-offset-2':
86
+ !buttonDisabled,
87
+ 'cursor-not-allowed': buttonDisabled
88
+ }
89
+ )}
90
+ aria-label='Submit Button'>
91
+ {loading ? (
92
+ <Spinner className='h-5 w-5 text-neutral-500' />
93
+ ) : (
94
+ <Icon
95
+ name='send'
96
+ className={clsx('h-4 w-4 pr-0.5 pt-0.5 transition-colors duration-150', {
97
+ 'text-neutral-50': !buttonDisabled,
98
+ 'text-neutral-500': buttonDisabled
99
+ })}
100
+ />
101
+ )}
102
+ </button>
103
+ </div>
104
+ )
105
+ }
106
+ )
30
107
 
31
108
  ChatInput.displayName = 'ChatInput'
32
109
 
@@ -0,0 +1,3 @@
1
+ .textArea {
2
+ --custom-scrollbar-width: var(--hc-size-spacing-1);
3
+ }
@@ -1,4 +1,7 @@
1
1
  export type ChatInputProps = {
2
2
  name: string
3
3
  onSend?: () => void
4
+ loading?: boolean
5
+ inputDisabled?: boolean
6
+ buttonDisabled?: boolean
4
7
  }
@@ -1,4 +1,5 @@
1
1
  export * from './chat-input'
2
2
  export * from './message-img'
3
3
  export * from './message-item'
4
+ export * from './message-skeleton'
4
5
  export * from './messages-list'
@@ -0,0 +1 @@
1
+ export { default as MessageSkeleton } from './message-skeleton'
@@ -0,0 +1,23 @@
1
+ import { forwardRef } from 'react'
2
+
3
+ import { AIAvatarIcon } from '@/src/modules/widget'
4
+
5
+ const MessageSkeleton = forwardRef<HTMLDivElement>((_, ref) => {
6
+ return (
7
+ <div
8
+ ref={ref}
9
+ className='flex max-w-[86%] flex-col items-start gap-2'
10
+ aria-label='Loading Component'>
11
+ <AIAvatarIcon className='rounded-lg bg-ai-chat-response' />
12
+ <div className='flex w-full flex-col items-start gap-2'>
13
+ <div className='h-3 w-full animate-pulse rounded-full bg-neutral-800 transition-colors' />
14
+ <div className='h-3 w-[83%] animate-pulse rounded-full bg-neutral-800 transition-colors delay-75' />
15
+ <div className='h-3 w-[56%] animate-pulse rounded-full bg-neutral-800 transition-colors delay-100' />
16
+ </div>
17
+ </div>
18
+ )
19
+ })
20
+
21
+ MessageSkeleton.displayName = 'MessageSkeleton'
22
+
23
+ export default MessageSkeleton
@@ -2,8 +2,11 @@ import { lazy, useCallback, useRef } from 'react'
2
2
  import clsx from 'clsx'
3
3
 
4
4
  import { useRefClientHeight } from '@/src/lib/hooks'
5
+ import { useWidgetLoadingAtomValue } from '@/src/modules/widget'
5
6
  import { useAllMessages, useManageScroll } from '../../hooks'
7
+ import { useSkeletonRef } from '../../hooks/use-skeleton-ref'
6
8
  import { MessageItem } from '../message-item'
9
+ import { MessageSkeleton } from '../message-skeleton'
7
10
 
8
11
  const MessageItemError = lazy(() => import('../message-item-error/message-item-error'))
9
12
 
@@ -22,7 +25,8 @@ function MessagesList() {
22
25
  const scrollerClientHeight = useRefClientHeight(scrollerRef)
23
26
  const scrollToButtonRef = useRef<HTMLButtonElement>(null)
24
27
  const { allMessages, messagesQuery } = useAllMessages()
25
-
28
+ const widgetIsLoading = useWidgetLoadingAtomValue()
29
+ const skeletonRef = useSkeletonRef()
26
30
  const { showScrollButton } = useManageScroll(scrollerRef)
27
31
 
28
32
  const scrollToBottom = useCallback(() => {
@@ -76,6 +80,15 @@ function MessagesList() {
76
80
  message={`❌ Error loading messages: ${messagesQuery.error?.message ?? ''}`}
77
81
  retry={() => void messagesQuery.refetch()}
78
82
  />
83
+
84
+ <div
85
+ className={clsx({
86
+ 'pointer-events-none h-0 overflow-hidden opacity-0': !widgetIsLoading,
87
+ 'pb-4': widgetIsLoading
88
+ })}
89
+ ref={skeletonRef}>
90
+ <MessageSkeleton />
91
+ </div>
79
92
  </div>
80
93
  )
81
94
  }
@@ -2,3 +2,5 @@ export * from './use-all-messages'
2
2
  export * from './use-fetch-messages'
3
3
  export * from './use-infinite-get-messages'
4
4
  export * from './use-manage-scroll'
5
+ export * from './use-send-text-message'
6
+ export * from './use-subscribe-message-received-event'
@@ -1,4 +1,7 @@
1
+ import type MessageService from '@hotmart/sparkie/dist/MessageService'
2
+
1
3
  import { act, renderHook, waitFor } from '@/src/config/tests'
4
+ import { SparkieService } from '@/src/modules/sparkie'
2
5
  import { SparkieMessageServiceMock } from '@/src/modules/sparkie/__tests__/sparkie.mock'
3
6
  import IMessageWithSenderDataMock from '../../__tests__/imessage-with-sender-data.mock'
4
7
  import type { IMessageWithSenderData } from '../../types'
@@ -11,6 +14,10 @@ describe('useInfiniteGetMessages', () => {
11
14
  const mockEnabled = true
12
15
 
13
16
  beforeEach(() => {
17
+ vi.clearAllMocks()
18
+ vi.spyOn(SparkieService, 'getMessageService').mockResolvedValue(
19
+ SparkieMessageServiceMock as unknown as MessageService
20
+ )
14
21
  SparkieMessageServiceMock.getAll.mockClear()
15
22
  })
16
23
 
@@ -1,12 +1,10 @@
1
- import { useCallback, useEffect } from 'react'
2
1
  import type { UndefinedInitialDataInfiniteOptions } from '@tanstack/react-query'
3
- import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { useInfiniteQuery } from '@tanstack/react-query'
4
3
 
5
4
  import { formatTime } from '@/src/config/dayjs'
6
- import { SparkieService } from '@/src/modules/sparkie'
7
5
  import { MessagesService } from '../..'
8
6
  import { MSG_MAX_COUNT } from '../../constants'
9
- import type { FetchMessagesResponse, IMessage, ParsedMessage } from '../../types'
7
+ import type { FetchMessagesResponse, ParsedMessage } from '../../types'
10
8
  import { messagesParser } from '../../utils'
11
9
  import type { UseFetchMessagesProps } from '../use-fetch-messages'
12
10
 
@@ -71,25 +69,11 @@ function useInfiniteGetMessages({
71
69
  profileId,
72
70
  enabled = false
73
71
  }: Omit<UseFetchMessagesProps, 'currentMessages' | 'loadFirstPage'>) {
74
- const queryClient = useQueryClient()
75
- const query = getMessagesInfiniteQuery({ conversationId, profileId, enabled })
76
-
77
- const messageReceived = useCallback(
78
- (data: IMessage) => {
79
- if (data.conversationId !== conversationId) return
80
-
81
- void queryClient.invalidateQueries({ queryKey: query.queryKey })
82
- },
83
- [conversationId, queryClient, query.queryKey]
84
- )
85
-
86
- useEffect(() => {
87
- SparkieService.subscribeEvents({ messageReceived })
88
-
89
- return () => {
90
- SparkieService.removeEventSubscription({ messageReceived })
91
- }
92
- }, [messageReceived])
72
+ const query = getMessagesInfiniteQuery({
73
+ conversationId,
74
+ profileId,
75
+ enabled
76
+ })
93
77
 
94
78
  return useInfiniteQuery(query)
95
79
  }