app-tutor-ai-consumer 1.21.1 → 1.22.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 (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/config/rspack/utils/envs.js +2 -1
  3. package/config/vitest/__mocks__/animation-avatar.tsx +3 -0
  4. package/config/vitest/vitest.config.mts +1 -0
  5. package/environments/.env.development +2 -0
  6. package/environments/.env.production +2 -0
  7. package/environments/.env.staging +3 -0
  8. package/environments/.env.test +4 -1
  9. package/package.json +2 -1
  10. package/src/config/styles/index.css +26 -0
  11. package/src/lib/components/button/button-default.tsx +35 -0
  12. package/src/lib/components/button/button.tsx +24 -33
  13. package/src/lib/components/horizontal-draggable-scroll/horizontal-draggable-scroll.tsx +62 -0
  14. package/src/lib/components/horizontal-draggable-scroll/index.ts +2 -0
  15. package/src/lib/components/icons/clone.svg +5 -0
  16. package/src/lib/components/icons/double-check.svg +5 -0
  17. package/src/lib/components/icons/icon-names.d.ts +7 -3
  18. package/src/lib/components/icons/paste.svg +5 -0
  19. package/src/lib/components/index.ts +1 -0
  20. package/src/main/main.tsx +2 -2
  21. package/src/modules/messages/components/message-actions/message-actions.tsx +5 -2
  22. package/src/modules/messages/components/message-item/message-item.tsx +7 -3
  23. package/src/modules/messages/components/message-item-error/message-item-error.tsx +1 -1
  24. package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +8 -5
  25. package/src/modules/messages/components/message-skeleton/styles.module.css +19 -0
  26. package/src/modules/messages/components/messages-container/messages-container.tsx +90 -67
  27. package/src/modules/messages/constants.ts +2 -0
  28. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +30 -28
  29. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +30 -28
  30. package/src/modules/messages/index.ts +1 -0
  31. package/src/modules/messages/service.direct.ts +41 -0
  32. package/src/modules/messages/types.ts +22 -1
  33. package/src/modules/sparkie/hooks/index.ts +1 -0
  34. package/src/modules/sparkie/hooks/use-init-sparkie/index.ts +2 -0
  35. package/src/modules/sparkie/hooks/use-init-sparkie/use-init-sparkie.tsx +39 -0
  36. package/src/modules/widget/components/avatar-animation/avatar-animation.tsx +13 -0
  37. package/src/modules/widget/components/avatar-animation/index.ts +2 -0
  38. package/src/modules/widget/components/chat-page/chat-page.tsx +7 -11
  39. package/src/modules/widget/components/container/container.tsx +2 -10
  40. package/src/modules/widget/components/index.ts +1 -0
  41. package/src/modules/widget/components/starter-page/starter-page.spec.tsx +4 -0
  42. package/src/modules/widget/components/starter-page/starter-page.tsx +23 -8
  43. package/src/modules/widget/hooks/use-init-widget/use-init-widget.tsx +0 -11
  44. package/src/modules/widget/store/widget-tabs.atom.ts +2 -2
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ # [1.22.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.21.2...v1.22.0) (2025-08-04)
2
+
3
+ ### Features
4
+
5
+ - add button default ([e10c478](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/e10c47844c68815df58a942250fef1fcd1d0f676))
6
+ - add useInitSparkie ([f3dd5f9](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/f3dd5f9d9542f755126e4fcc2af8e8a500703753))
7
+
8
+ ## [1.21.2](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.21.1...v1.21.2) (2025-08-01)
9
+
1
10
  ## [1.21.1](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.21.0...v1.21.1) (2025-07-31)
2
11
 
3
12
  # [1.21.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.20.0...v1.21.0) (2025-07-29)
@@ -20,7 +20,8 @@ const envs = [
20
20
  'VLC_PASSWORD',
21
21
  'SECURITY_BASE_URL',
22
22
  'AUTH_CLIENT_SECRET',
23
- 'BUNDLE_PATH'
23
+ 'BUNDLE_PATH',
24
+ 'STATIC_URL'
24
25
  ]
25
26
 
26
27
  const localEnvs = ['MEMBERSHIP_SLUG']
@@ -0,0 +1,3 @@
1
+ vi.mock('@/src/modules/widget/components/avatar-animation', () => ({
2
+ AvatarAnimation: vi.fn(() => <div data-test='avatar-animation-icon'>AvatarAnimation</div>)
3
+ }))
@@ -13,6 +13,7 @@ export default defineConfig({
13
13
  './config/vitest/__mocks__/i18n.tsx',
14
14
  './config/vitest/__mocks__/sparkie.tsx',
15
15
  './config/vitest/__mocks__/icons.tsx',
16
+ './config/vitest/__mocks__/animation-avatar.tsx',
16
17
  './config/vitest/__mocks__/intersection-observer.ts',
17
18
  './config/vitest/polyfills/global.js'
18
19
  ],
@@ -40,3 +40,5 @@ AUTH_CLIENT_SECRET=Basic OWRiYThiNTYtMWE5OC0xMWU5LWFiMTQtZDY2M2JkODczZDkzOmFiZmR
40
40
  # BUNDLE
41
41
  BUNDLE_PATH=https://local.buildstaging.com:4223
42
42
 
43
+ # STATIC
44
+ STATIC_URL=https://static.hotmart.com
@@ -39,3 +39,5 @@ AUTH_CLIENT_SECRET=
39
39
  # BUNDLE
40
40
  BUNDLE_PATH=https://app-club-microfrontends-prod.cp.hotmart.com
41
41
 
42
+ # STATIC
43
+ STATIC_URL=https://static.hotmart.com
@@ -38,3 +38,6 @@ AUTH_CLIENT_SECRET=
38
38
 
39
39
  # BUNDLE
40
40
  BUNDLE_PATH=https://app-club-microfrontends.buildstaging.com
41
+
42
+ # STATIC
43
+ STATIC_URL=https://static.hotmart.com
@@ -40,4 +40,7 @@ AUTH_CLIENT_SECRET=
40
40
  # BUNDLE
41
41
  BUNDLE_PATH=
42
42
 
43
- VITE_REACT_QUERY_EXPERIMENTAL_PREFETCH_IN_RENDER=true
43
+ VITE_REACT_QUERY_EXPERIMENTAL_PREFETCH_IN_RENDER=true
44
+
45
+ # STATIC
46
+ STATIC_URL=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-tutor-ai-consumer",
3
- "version": "1.21.1",
3
+ "version": "1.22.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "dev": "rspack serve --env=development --config config/rspack/rspack.config.js",
@@ -106,6 +106,7 @@
106
106
  "@hot-observability-js/react": "~1.1.0",
107
107
  "@hotmart/event-agent-js": "~1.1.2",
108
108
  "@hotmart/sparkie": "~5.1.0",
109
+ "@lottiefiles/dotlottie-react": "~0.14.4",
109
110
  "@optimizely/react-sdk": "~3.2.4",
110
111
  "@tanstack/query-sync-storage-persister": "~5.80.7",
111
112
  "@tanstack/react-query": "~5.80.6",
@@ -3,3 +3,29 @@
3
3
  @tailwind base;
4
4
  @tailwind components;
5
5
  @tailwind utilities;
6
+
7
+ @layer components {
8
+ .shine-box {
9
+ background-color: var(--shb-bg-color, var(--hc-color-neutral-200));
10
+ background-size: 200% 100%;
11
+ background-image: linear-gradient(
12
+ 90deg,
13
+ transparent 0%,
14
+ rgb(from var(--shb-shine-color, var(--hc-color-neutral-900)) r g b / 0.3) 50%,
15
+ transparent 100%
16
+ );
17
+ animation-name: slideShine;
18
+ animation-duration: 1s;
19
+ animation-timing-function: cubic-bezier(0.5, -0.5, 1, 1.5);
20
+ animation-iteration-count: infinite;
21
+ }
22
+
23
+ @keyframes slideShine {
24
+ 0% {
25
+ background-position: 100% 0;
26
+ }
27
+ 100% {
28
+ background-position: -100% 0;
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,35 @@
1
+ import clsx from 'clsx'
2
+ import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
3
+
4
+ export type ButtonDefaultProps = PropsWithChildren<
5
+ ButtonHTMLAttributes<HTMLButtonElement> & {
6
+ show?: boolean
7
+ loading?: boolean
8
+ }
9
+ >
10
+
11
+ function ButtonDefault({
12
+ loading,
13
+ className,
14
+ children,
15
+ show = true,
16
+ ...props
17
+ }: ButtonDefaultProps) {
18
+ if (!show) return null
19
+
20
+ return (
21
+ <button
22
+ {...props}
23
+ className={clsx(
24
+ 'rounded text-base font-medium outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
25
+ className
26
+ )}
27
+ type={props.type ?? 'button'}
28
+ disabled={props.disabled || loading}
29
+ aria-busy={loading}>
30
+ {children}
31
+ </button>
32
+ )
33
+ }
34
+
35
+ export default ButtonDefault
@@ -1,8 +1,11 @@
1
1
  import clsx from 'clsx'
2
- import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
2
+ import type { PropsWithChildren } from 'react'
3
3
 
4
4
  import { Spinner } from '../spinner'
5
5
 
6
+ import type { ButtonDefaultProps } from './button-default'
7
+ import ButtonDefault from './button-default'
8
+
6
9
  import styles from './styles.module.css'
7
10
 
8
11
  function ButtonContent({
@@ -25,13 +28,9 @@ function ButtonContent({
25
28
  )
26
29
  }
27
30
 
28
- export type ButtonProps = PropsWithChildren<
29
- ButtonHTMLAttributes<HTMLButtonElement> & {
30
- variant?: 'brand' | 'secondary' | 'primary' | 'tertiary' | 'gradient-outline'
31
- show?: boolean
32
- loading?: boolean
33
- }
34
- >
31
+ export type ButtonProps = ButtonDefaultProps & {
32
+ variant?: 'brand' | 'secondary' | 'primary' | 'tertiary' | 'gradient-outline'
33
+ }
35
34
 
36
35
  function Button({
37
36
  children,
@@ -42,7 +41,7 @@ function Button({
42
41
  ...props
43
42
  }: ButtonProps) {
44
43
  const defaultClasses =
45
- 'rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 text-base font-medium'
44
+ 'rounded outline-none focus-visible:ring-2 focus-visible:ring-blue-500 text-base font-medium'
46
45
  const defaultBorder = 'border border-transparent'
47
46
  const defaultPadding = 'px-4 py-2'
48
47
  const disabledClasses = 'cursor-not-allowed'
@@ -53,35 +52,28 @@ function Button({
53
52
  switch (variant) {
54
53
  case 'gradient-outline':
55
54
  return (
56
- <button
57
- className={clsx(
58
- gridClasses,
59
- defaultClasses,
60
- 'group relative inline-flex items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-[var(--ai-color-gradient-primary)] to-[var(--ai-color-gradient-accent)] p-[1px] hover:text-neutral-1000 group-hover:from-[var(--ai-color-gradient-primary)] group-hover:to-[var(--ai-color-gradient-accent)]',
61
- className
62
- )}
55
+ <ButtonDefault
63
56
  {...props}
64
- disabled={props.disabled || loading}
65
- aria-busy={loading}>
57
+ loading={loading}
58
+ show={show}
59
+ className={clsx(
60
+ className,
61
+ 'group relative inline-flex items-center justify-center overflow-hidden rounded-lg p-[1px]',
62
+ {
63
+ 'bg-gradient-to-br from-[var(--ai-color-gradient-primary)] to-[var(--ai-color-gradient-accent)] hover:text-neutral-1000 group-hover:from-[var(--ai-color-gradient-primary)] group-hover:to-[var(--ai-color-gradient-accent)]':
64
+ !loading,
65
+ 'shine-box cursor-not-allowed': loading
66
+ }
67
+ )}>
66
68
  <span
67
69
  data-label='text-content'
68
70
  className={clsx(
69
- 'relative flex-1 rounded-lg bg-neutral-100 px-5 py-2.5 text-neutral-1000 transition-all duration-75 ease-in group-hover:bg-transparent',
70
- 'flex flex-nowrap gap-2 [grid-area:stack]',
71
- {
72
- visible: !loading,
73
- invisible: loading
74
- }
71
+ 'flex-1 rounded-lg bg-neutral-100 px-5 py-2.5 text-neutral-1000 transition-all duration-75 ease-in group-hover:bg-transparent',
72
+ 'flex flex-nowrap'
75
73
  )}>
76
- {children}
74
+ <span className={clsx({ 'opacity-20': loading })}>{children}</span>
77
75
  </span>
78
- <Spinner
79
- className={clsx('mx-auto my-auto h-[1em] w-[1em] text-current', '[grid-area:stack]', {
80
- visible: loading,
81
- invisible: !loading
82
- })}
83
- />
84
- </button>
76
+ </ButtonDefault>
85
77
  )
86
78
  case 'primary':
87
79
  return (
@@ -152,7 +144,6 @@ function Button({
152
144
  return (
153
145
  <button
154
146
  className={clsx(
155
- gridClasses,
156
147
  'rounded-full outline-none transition-colors duration-300 ease-in',
157
148
  {
158
149
  'cursor-pointer ring-primary-500 hover:bg-neutral-300 focus:bg-neutral-300 focus-visible:ring-2':
@@ -0,0 +1,62 @@
1
+ import { useRef, useState } from 'react'
2
+ import clsx from 'clsx'
3
+ import type { MouseEventHandler, PropsWithChildren, WheelEvent } from 'react'
4
+
5
+ export type HorizontalDraggableScrollProps = PropsWithChildren<{ className?: string }>
6
+
7
+ function HorizontalDraggableScroll({ children, className }: HorizontalDraggableScrollProps) {
8
+ const containerRef = useRef<HTMLDivElement>(null)
9
+ const [isDragging, setIsDragging] = useState(false)
10
+ const [startX, setStartX] = useState(0)
11
+ const [scrollLeft, setScrollLeft] = useState(0)
12
+
13
+ const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
14
+ const container = containerRef.current
15
+
16
+ if (!container) return
17
+
18
+ setIsDragging(true)
19
+ setStartX(e.clientX)
20
+ setScrollLeft(container.scrollLeft)
21
+ }
22
+
23
+ const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
24
+ const container = containerRef.current
25
+
26
+ if (!container || !isDragging) return
27
+
28
+ e.preventDefault()
29
+
30
+ const walk = e.clientX - startX
31
+
32
+ container.scrollLeft = scrollLeft - walk
33
+ }
34
+
35
+ const handleMouseUp = () => {
36
+ setIsDragging(false)
37
+ }
38
+
39
+ const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
40
+ e.currentTarget.scrollLeft += e.deltaY
41
+ }
42
+
43
+ return (
44
+ <div
45
+ ref={containerRef}
46
+ onMouseDown={handleMouseDown}
47
+ onMouseMove={handleMouseMove}
48
+ onMouseUp={handleMouseUp}
49
+ onMouseLeave={handleMouseUp}
50
+ onWheel={handleWheel}
51
+ className={clsx(
52
+ {
53
+ 'cursor-grabbing select-none': isDragging
54
+ },
55
+ className
56
+ )}>
57
+ {children}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ export default HorizontalDraggableScroll
@@ -0,0 +1,2 @@
1
+ export * from './horizontal-draggable-scroll'
2
+ export { default as HorizontalDraggableScroll } from './horizontal-draggable-scroll'
@@ -0,0 +1,5 @@
1
+ <svg aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg"
2
+ viewBox="0 0 512 512">
3
+ <path fill="currentColor"
4
+ d="M64 480H288c17.7 0 32-14.3 32-32V384h32v64c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h64v32H64c-17.7 0-32 14.3-32 32V448c0 17.7 14.3 32 32 32zM224 320H448c17.7 0 32-14.3 32-32V64c0-17.7-14.3-32-32-32H224c-17.7 0-32 14.3-32 32V288c0 17.7 14.3 32 32 32zm-64-32V64c0-35.3 28.7-64 64-64H448c35.3 0 64 28.7 64 64V288c0 35.3-28.7 64-64 64H224c-35.3 0-64-28.7-64-64z"></path>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg aria-hidden="true" focusable="false" role="img"
2
+ xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
3
+ <path fill="currentColor"
4
+ d="M331.3 75.3c6.2-6.2 6.2-16.4 0-22.6s-16.4-6.2-22.6 0L160 201.4 91.3 132.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l80 80c6.2 6.2 16.4 6.2 22.6 0l160-160zm112 112c6.2-6.2 6.2-16.4 0-22.6s-16.4-6.2-22.6 0L160 425.4 27.3 292.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l144 144c6.2 6.2 16.4 6.2 22.6 0l272-272z"></path>
5
+ </svg>
@@ -5,13 +5,17 @@ export type ValidIconNames =
5
5
  | 'arrow-down'
6
6
  | 'arrow-left'
7
7
  | 'arrow-up'
8
+ | 'book'
8
9
  | 'chevron-down'
9
- | 'copy'
10
+ | 'clone'
10
11
  | 'close'
12
+ | 'copy'
13
+ | 'double-check'
14
+ | 'gallery'
11
15
  | 'info'
16
+ | 'interrogation'
12
17
  | 'like'
18
+ | 'paste'
13
19
  | 'send'
14
20
  | 'stop'
15
21
  | 'warning'
16
- | 'interrogation'
17
- | 'gallery'
@@ -0,0 +1,5 @@
1
+ <svg aria-hidden="true" focusable="false" role="img" xmlns="http://www.w3.org/2000/svg"
2
+ viewBox="0 0 512 512">
3
+ <path fill="currentColor"
4
+ d="M160 32c11.6 0 21.3 8.2 23.5 19.2C185 58.6 191.6 64 199.2 64H208c8.8 0 16 7.2 16 16V96H96V80c0-8.8 7.2-16 16-16h8.8c7.6 0 14.2-5.4 15.7-12.8C138.7 40.2 148.4 32 160 32zM64 64h2.7C65 69 64 74.4 64 80V96c0 17.7 14.3 32 32 32H224c17.7 0 32-14.3 32-32V80c0-5.6-1-11-2.7-16H256c17.7 0 32 14.3 32 32h32c0-35.3-28.7-64-64-64H210.6c-9-18.9-28.3-32-50.6-32s-41.6 13.1-50.6 32H64C28.7 32 0 60.7 0 96V384c0 35.3 28.7 64 64 64H192V416H64c-17.7 0-32-14.3-32-32V96c0-17.7 14.3-32 32-32zM288 480c-17.7 0-32-14.3-32-32V192c0-17.7 14.3-32 32-32h96v56c0 22.1 17.9 40 40 40h56V448c0 17.7-14.3 32-32 32H288zM416 165.3L474.7 224H424c-4.4 0-8-3.6-8-8V165.3zM448 512c35.3 0 64-28.7 64-64V235.9c0-12.7-5.1-24.9-14.1-33.9l-59.9-59.9c-9-9-21.2-14.1-33.9-14.1H288c-35.3 0-64 28.7-64 64V448c0 35.3 28.7 64 64 64H448z"></path>
5
+ </svg>
@@ -1,5 +1,6 @@
1
1
  export * from './button'
2
2
  export * from './errors'
3
+ export * from './horizontal-draggable-scroll'
3
4
  export * from './icons'
4
5
  export * from './markdownrenderer'
5
6
  export * from './spinner'
package/src/main/main.tsx CHANGED
@@ -12,14 +12,14 @@ export type MainProps = {
12
12
  }
13
13
 
14
14
  function Main({ settings }: MainProps) {
15
- const { completeSetup } = useInitWidget(settings)
15
+ useInitWidget(settings)
16
16
  useAppLang(settings.locale)
17
17
  useListenToThemeChangeEvent()
18
18
 
19
19
  return (
20
20
  <ErrorBoundary fallback={<GenericError />}>
21
21
  <GlobalProviders settings={settings}>
22
- <WidgetContainer completeSetup={completeSetup} />
22
+ <WidgetContainer />
23
23
  </GlobalProviders>
24
24
  </ErrorBoundary>
25
25
  )
@@ -18,6 +18,7 @@ export type MessageActionsProps = {
18
18
  function MessageActions({ message, className, showActions = false }: MessageActionsProps) {
19
19
  const { t } = useTranslation()
20
20
  const [copying, setCopying] = useState(false)
21
+ const [copied, setCopied] = useState(false)
21
22
  const [reaction, setReaction] = useState<ButtonReactionsType | null>(null)
22
23
 
23
24
  const copyToClipboard = (): void => {
@@ -27,11 +28,13 @@ function MessageActions({ message, className, showActions = false }: MessageActi
27
28
 
28
29
  navigator.clipboard
29
30
  .writeText(message.text)
31
+ .then(() => setCopied(true))
30
32
  .catch((err) => {
31
33
  console.error('Failed to copy text: ', err)
32
34
  })
33
35
  .finally(() => {
34
36
  setTimeout(() => setCopying(false), 1000)
37
+ setTimeout(() => setCopied(false), 3000)
35
38
  })
36
39
  }
37
40
 
@@ -72,9 +75,9 @@ function MessageActions({ message, className, showActions = false }: MessageActi
72
75
  </Button>
73
76
  <Button onClick={copyToClipboard} aria-label={t('general.buttons.copy')} disabled={copying}>
74
77
  <Icon
75
- name='copy'
78
+ name={copied ? 'paste' : 'copy'}
76
79
  className={clsx('h-3 w-3.5', {
77
- 'text-info-500': copying
80
+ 'text-info-500': copied
78
81
  })}
79
82
  />
80
83
  </Button>
@@ -25,10 +25,14 @@ function MessageItem({ message }: { message: ParsedMessage }) {
25
25
  <div
26
26
  data-test='messages-item'
27
27
  className={clsx('w-full overflow-x-hidden rounded-lg px-3', {
28
- 'bg-neutral-200': messageFromUser,
29
- 'border border-neutral-300 bg-neutral-100': messageFromAi
28
+ 'max-w-max bg-[rgb(from_var(--hc-color-neutral-300)_r_g_b_/_0.8)]': messageFromUser,
29
+ 'bg-neutral-200': messageFromAi
30
30
  })}>
31
- <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
31
+ <MarkdownRenderer
32
+ content={message?.text ?? message?.name}
33
+ imgComponent={imgComponent}
34
+ className='w-full'
35
+ />
32
36
  </div>
33
37
  <MessageActions
34
38
  className={clsx('flex items-center justify-between gap-2', {
@@ -20,7 +20,7 @@ function MessageItemError({ message, retry, show = true }: MessageItemErrorProps
20
20
  if (!show) return null
21
21
 
22
22
  return (
23
- <div className='mt-2 flex justify-start gap-2 rounded border border-danger-500 bg-neutral-200 p-4 text-neutral-800'>
23
+ <div className='absolute bottom-4 flex justify-start gap-2 rounded border border-danger-500 bg-neutral-200 p-4 text-neutral-800 max-md:inset-x-[1.125rem] md:inset-x-5'>
24
24
  <span>{t('chat_page.error.loading.content')}</span>
25
25
  <Button variant='tertiary' className='px-4 text-sm/relaxed text-danger-400' onClick={retry}>
26
26
  {t('chat_page.error.loading.action')}
@@ -1,15 +1,18 @@
1
1
  import { forwardRef } from 'react'
2
+ import clsx from 'clsx'
2
3
 
3
- import { AIAvatarIcon } from '@/src/modules/widget'
4
+ import { AvatarAnimation } from '@/src/modules/widget'
5
+
6
+ import styles from './styles.module.css'
4
7
 
5
8
  const MessageSkeleton = forwardRef<HTMLDivElement>((_, ref) => {
6
9
  return (
7
10
  <div ref={ref} className='flex flex-col items-start gap-2' aria-label='Loading Component'>
8
- <AIAvatarIcon className='rounded-lg bg-ai-chat-response' />
11
+ <AvatarAnimation />
9
12
  <div className='flex w-full flex-col items-start gap-2'>
10
- <div className='h-3 w-full animate-pulse rounded-full bg-neutral-200 transition-colors delay-0' />
11
- <div className='h-3 w-[83%] animate-pulse rounded-full bg-neutral-200 transition-colors delay-75' />
12
- <div className='h-3 w-[56%] animate-pulse rounded-full bg-neutral-200 transition-colors delay-100' />
13
+ <div className={clsx('h-3 w-full rounded-full', styles.shine)} />
14
+ <div className={clsx('h-3 w-[83%] rounded-full', styles.shine)} />
15
+ <div className={clsx('h-3 w-[56%] rounded-full', styles.shine)} />
13
16
  </div>
14
17
  </div>
15
18
  )
@@ -0,0 +1,19 @@
1
+ .shine {
2
+ background: linear-gradient(
3
+ 90deg,
4
+ var(--hc-color-neutral-200) 33%,
5
+ rgb(from var(--hc-color-neutral-900) r g b / 0.3) 53%,
6
+ var(--hc-color-neutral-200) 76%
7
+ );
8
+ background-size: 295% 100%;
9
+ animation: shine 1500ms infinite alternate;
10
+ }
11
+
12
+ @keyframes shine {
13
+ 0% {
14
+ background-position: right;
15
+ }
16
+ 100% {
17
+ background-position: left;
18
+ }
19
+ }
@@ -1,4 +1,4 @@
1
- import { forwardRef, useEffect } from 'react'
1
+ import { forwardRef, lazy, Suspense, useEffect } from 'react'
2
2
  import clsx from 'clsx'
3
3
  import type { MouseEventHandler, PropsWithChildren } from 'react'
4
4
  import { createPortal } from 'react-dom'
@@ -14,79 +14,102 @@ import {
14
14
  } from '@/src/modules/widget'
15
15
  import { useScroller } from '../../hooks'
16
16
 
17
- const MessagesContainer = forwardRef<
18
- HTMLDivElement,
19
- PropsWithChildren<{
20
- showButton?: boolean
21
- loading?: boolean
22
- handleShowMore?: () => Promise<void>
23
- }>
24
- >(({ children, handleShowMore, showButton = false, loading = false }, forwardedRef) => {
25
- const { t } = useTranslation()
26
- const skeletonRef = useSkeletonRef()
27
- const [isLoadingNewMsg] = useWidgetLoadingAtom()
28
- const mainLayoutRef = usePageLayoutMainRefContext()
29
- const { scrollerRef, scrollToButtonRef, scrollToBottom, showScrollButton } =
30
- useScroller(forwardedRef)
17
+ const MessageItemError = lazy(
18
+ () => import('@/src/modules/messages/components/message-item-error/message-item-error')
19
+ )
31
20
 
32
- useEffect(() => {
33
- scrollToBottom()
34
- }, [scrollToBottom])
21
+ export type MessagesContainerProps = PropsWithChildren<{
22
+ showButton?: boolean
23
+ loading?: boolean
24
+ handleShowMore?: () => Promise<void>
25
+ error?: {
26
+ show: boolean
27
+ message: string
28
+ retry: () => void
29
+ }
30
+ }>
35
31
 
36
- const handleClickShowMore: MouseEventHandler<HTMLButtonElement> = (e) => {
37
- const scroller = scrollerRef?.current
38
- const heightBeforeRender = Number(e?.currentTarget?.scrollHeight)
32
+ const MessagesContainer = forwardRef<HTMLDivElement, MessagesContainerProps>(
33
+ ({ children, handleShowMore, error, showButton = false, loading = false }, forwardedRef) => {
34
+ const { t } = useTranslation()
35
+ const skeletonRef = useSkeletonRef()
36
+ const [isLoadingNewMsg] = useWidgetLoadingAtom()
37
+ const mainLayoutRef = usePageLayoutMainRefContext()
38
+ const { scrollerRef, scrollToButtonRef, scrollToBottom, showScrollButton } =
39
+ useScroller(forwardedRef)
39
40
 
40
- void handleShowMore?.().then(() => {
41
- if (scroller && !isNaN(heightBeforeRender)) {
42
- setTimeout(
43
- () =>
44
- scroller.scrollTo({
45
- top: heightBeforeRender + 10,
46
- behavior: 'smooth'
47
- }),
48
- 180
49
- )
50
- }
51
- })
52
- }
41
+ useEffect(() => {
42
+ scrollToBottom()
43
+ }, [scrollToBottom])
53
44
 
54
- return (
55
- <div
56
- ref={scrollerRef}
57
- className='flex h-full flex-col gap-2 overflow-auto max-md:p-[1.125rem] md:p-5'>
58
- <div className='mb-auto flex-1 self-center'>
59
- <Button
60
- className='max-w-max rounded-full border border-neutral-300 bg-neutral-200 px-2 py-1 text-xs/normal tracking-wide text-neutral-900'
61
- onClick={handleClickShowMore}
62
- loading={loading}
63
- show={showButton}>
64
- <Icon name='arrow-up' className='h-4 w-3' aria-hidden />
65
- <span className='text-nowrap'>{t('general.buttons.show_more')}</span>
66
- </Button>
67
- </div>
68
- {children}
45
+ const handleClickShowMore: MouseEventHandler<HTMLButtonElement> = (e) => {
46
+ const scroller = scrollerRef?.current
47
+ const heightBeforeRender = Number(e?.currentTarget?.scrollHeight)
48
+
49
+ void handleShowMore?.().then(() => {
50
+ if (scroller && !isNaN(heightBeforeRender)) {
51
+ setTimeout(
52
+ () =>
53
+ scroller.scrollTo({
54
+ top: heightBeforeRender + 10,
55
+ behavior: 'smooth'
56
+ }),
57
+ 180
58
+ )
59
+ }
60
+ })
61
+ }
69
62
 
70
- {mainLayoutRef.current &&
71
- createPortal(
72
- <ScrollToBottomButton
73
- ref={scrollToButtonRef}
74
- show={showScrollButton}
75
- onClick={scrollToBottom}
76
- />,
77
- mainLayoutRef.current
78
- )}
63
+ return (
79
64
  <div
80
- className={clsx({
81
- 'pointer-events-none h-0 overflow-hidden opacity-0': !isLoadingNewMsg,
82
- 'mt-2 pb-4': isLoadingNewMsg
83
- })}
84
- ref={skeletonRef}>
85
- <MessageSkeleton />
65
+ ref={scrollerRef}
66
+ className='flex h-full flex-col gap-2 overflow-auto max-md:p-[1.125rem] md:p-5'>
67
+ <div className='mb-auto flex-1 self-center'>
68
+ <Button
69
+ className='max-w-max rounded-full border border-neutral-300 bg-neutral-200 px-2 py-1 text-xs/normal tracking-wide text-neutral-900'
70
+ onClick={handleClickShowMore}
71
+ loading={loading}
72
+ show={showButton}>
73
+ <Icon name='arrow-up' className='h-4 w-3' aria-hidden />
74
+ <span className='text-nowrap'>{t('general.buttons.show_more')}</span>
75
+ </Button>
76
+ </div>
77
+ {children}
78
+
79
+ {error?.show &&
80
+ mainLayoutRef.current &&
81
+ createPortal(
82
+ <Suspense fallback={<div aria-live='polite' />}>
83
+ <MessageItemError
84
+ message={`❌ Error loading messages: ${error?.message}`}
85
+ show={error?.show}
86
+ retry={error?.retry}
87
+ />
88
+ </Suspense>,
89
+ mainLayoutRef.current
90
+ )}
91
+
92
+ {mainLayoutRef.current &&
93
+ createPortal(
94
+ <ScrollToBottomButton
95
+ ref={scrollToButtonRef}
96
+ show={showScrollButton}
97
+ onClick={scrollToBottom}
98
+ />,
99
+ mainLayoutRef.current
100
+ )}
101
+ <div
102
+ className={clsx({
103
+ 'pointer-events-none h-0 overflow-hidden opacity-0': !isLoadingNewMsg,
104
+ 'mt-2 pb-4': isLoadingNewMsg
105
+ })}
106
+ ref={skeletonRef}>
107
+ <MessageSkeleton />
108
+ </div>
86
109
  </div>
87
- </div>
88
- )
89
- })
110
+ )
111
+ }
112
+ )
90
113
 
91
114
  MessagesContainer.displayName = 'MessagesContainer'
92
115
 
@@ -3,5 +3,7 @@ export const MSG_MAX_PAGES = 20
3
3
 
4
4
  export const MessagesEndpoints = {
5
5
  getAll: (conversationId: string) =>
6
+ `${process.env.API_CONVERSATION_URL}/v1/conversations/${conversationId}/messages`,
7
+ create: (conversationId: string) =>
6
8
  `${process.env.API_CONVERSATION_URL}/v1/conversations/${conversationId}/messages`
7
9
  }
@@ -1,7 +1,7 @@
1
1
  import { UAParser } from 'ua-parser-js'
2
2
 
3
3
  import { act, chance, renderHook, waitFor } from '@/src/config/tests'
4
- import { MessagesService } from '@/src/modules/messages'
4
+ import { DirectMessagesService as MessagesService } from '@/src/modules/messages'
5
5
  import { useGetProfile } from '@/src/modules/profile'
6
6
  import * as Store from '@/src/modules/widget'
7
7
  import WidgetSettingPropsBuilder from '@/src/modules/widget/__tests__/widget-settings-props.builder'
@@ -57,40 +57,42 @@ describe('useSendTextMessage', () => {
57
57
  const txt = 'question::summary'
58
58
  const text = txt.replace('question::', '')
59
59
 
60
- vi.spyOn(MessagesService, 'sendTextMessage')
60
+ vi.spyOn(MessagesService, 'create')
61
61
 
62
62
  const { result } = render()
63
63
 
64
64
  await waitFor(() => result.current.mutateAsync(txt))
65
65
 
66
- expect(MessagesService.sendTextMessage).toHaveBeenCalledTimes(1)
67
- expect(MessagesService.sendTextMessage).toHaveBeenNthCalledWith(1, {
68
- content: {
69
- type: 'text/plain',
70
- text
71
- },
66
+ expect(MessagesService.create).toHaveBeenCalledTimes(1)
67
+ expect(MessagesService.create).toHaveBeenNthCalledWith(1, {
72
68
  conversationId: defaultSettings.conversationId,
73
- metadata: {
74
- author: 'user',
75
- contactId: defaultSettings.contactId,
76
- context: {
77
- language: defaultSettings.locale,
78
- clubName: defaultSettings.clubName,
79
- productName: defaultSettings.productName,
80
- productId: defaultSettings.productId,
81
- classHashId: defaultSettings.classHashId,
82
- owner_id: defaultSettings.owner_id,
83
- current_media_codes: defaultSettings.current_media_codes,
84
- question: text,
85
- router: text,
86
- osVersion: UAParserMock.browser.version,
87
- platformDetail: UAParserMock.browser.name,
88
- ucode: defaultSettings.user?.ucode
69
+ message: {
70
+ content: {
71
+ type: 'text/plain',
72
+ text
89
73
  },
90
- externalId: expect.any(String),
91
- namespace: defaultSettings.namespace,
92
- sessionId: defaultSettings.sessionId,
93
- userId: String(getProfileMock.data.userId)
74
+ metadata: {
75
+ author: 'user',
76
+ contactId: defaultSettings.contactId,
77
+ context: {
78
+ language: defaultSettings.locale,
79
+ clubName: defaultSettings.clubName,
80
+ productName: defaultSettings.productName,
81
+ productId: defaultSettings.productId,
82
+ classHashId: defaultSettings.classHashId,
83
+ owner_id: defaultSettings.owner_id,
84
+ current_media_codes: defaultSettings.current_media_codes,
85
+ question: text,
86
+ router: text,
87
+ osVersion: UAParserMock.browser.version,
88
+ platformDetail: UAParserMock.browser.name,
89
+ ucode: defaultSettings.user?.ucode
90
+ },
91
+ externalId: expect.any(String),
92
+ namespace: defaultSettings.namespace,
93
+ sessionId: defaultSettings.sessionId,
94
+ userId: String(getProfileMock.data.userId)
95
+ }
94
96
  }
95
97
  })
96
98
  })
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
3
3
  import { UAParser } from 'ua-parser-js'
4
4
  import { v4 } from 'uuid'
5
5
 
6
- import { MessagesService } from '@/src/modules/messages'
6
+ import { DirectMessagesService as MessagesService } from '@/src/modules/messages'
7
7
  import { useGetProfile } from '@/src/modules/profile'
8
8
  import { useWidgetLoadingAtom, useWidgetSettingsAtomValue } from '@/src/modules/widget'
9
9
  import { MessagesEvents } from '../../events'
@@ -47,35 +47,37 @@ function useSendTextMessage() {
47
47
  questionParam = processedMessage
48
48
  }
49
49
 
50
- return MessagesService.sendTextMessage({
51
- content: {
52
- type: 'text/plain',
53
- text: processedMessage
54
- },
50
+ return MessagesService.create({
55
51
  conversationId: settings?.conversationId,
56
- metadata: {
57
- author: 'user',
58
- contactId: settings.contactId,
59
- context: {
60
- language: settings.locale,
61
- clubName: settings.clubName,
62
- productName: settings.productName,
63
- productId: settings.productId,
64
- productType: settings.productType,
65
- classType: settings.classType,
66
- classHashId: settings.classHashId,
67
- owner_id: settings?.owner_id,
68
- current_media_codes: settings?.current_media_codes,
69
- question: questionParam,
70
- router: questionParam ? 'summary' : undefined,
71
- osVersion: browserInfo.version,
72
- platformDetail: browserInfo.name,
73
- ucode: settings.user?.ucode
52
+ message: {
53
+ content: {
54
+ type: 'text/plain',
55
+ text: processedMessage
74
56
  },
75
- externalId: v4(),
76
- namespace: settings.namespace,
77
- sessionId: settings.sessionId,
78
- userId
57
+ metadata: {
58
+ author: 'user',
59
+ contactId: settings.contactId,
60
+ context: {
61
+ language: settings.locale,
62
+ clubName: settings.clubName,
63
+ productName: settings.productName,
64
+ productId: settings.productId,
65
+ productType: settings.productType,
66
+ classType: settings.classType,
67
+ classHashId: settings.classHashId,
68
+ owner_id: settings?.owner_id,
69
+ current_media_codes: settings?.current_media_codes,
70
+ question: questionParam,
71
+ router: questionParam ? 'summary' : undefined,
72
+ osVersion: browserInfo.version,
73
+ platformDetail: browserInfo.name,
74
+ ucode: settings.user?.ucode
75
+ },
76
+ externalId: v4(),
77
+ namespace: settings.namespace,
78
+ sessionId: settings.sessionId,
79
+ userId
80
+ }
79
81
  }
80
82
  })
81
83
  },
@@ -1,3 +1,4 @@
1
1
  export * from './constants'
2
2
  export { default as MessagesService } from './service'
3
+ export { default as DirectMessagesService } from './service.direct'
3
4
  export * from './types'
@@ -0,0 +1,41 @@
1
+ import type { Message } from '@hotmart/sparkie/dist/MessageService'
2
+
3
+ import { api } from '@/src/config/request'
4
+
5
+ import { MessagesEndpoints } from './constants'
6
+ import type {
7
+ DirectMessagesServiceProps,
8
+ FetchMessagesResponse,
9
+ IMessageWithSenderData
10
+ } from './types'
11
+
12
+ class DirectMessagesService {
13
+ async getAll({ conversationId, before, limit }: DirectMessagesServiceProps['GetAll']) {
14
+ const { data: messages } = await api.get<IMessageWithSenderData[]>(
15
+ MessagesEndpoints.getAll(conversationId),
16
+ {
17
+ params: {
18
+ before,
19
+ limit
20
+ }
21
+ }
22
+ )
23
+
24
+ return {
25
+ messages,
26
+ hasMore: messages.length === limit
27
+ } as FetchMessagesResponse
28
+ }
29
+
30
+ async create({ conversationId, message }: DirectMessagesServiceProps['Create']) {
31
+ const { data } = await api.post<Message>(MessagesEndpoints.create(conversationId), {
32
+ ...message,
33
+ type: 'message',
34
+ channel: 'chat'
35
+ })
36
+
37
+ return data
38
+ }
39
+ }
40
+
41
+ export default new DirectMessagesService()
@@ -1,4 +1,8 @@
1
- import type { Message as SparkieMsg, SenderData } from '@hotmart/sparkie/dist/MessageService'
1
+ import type {
2
+ Message as SparkieMsg,
3
+ MessageContent,
4
+ SenderData
5
+ } from '@hotmart/sparkie/dist/MessageService'
2
6
 
3
7
  export type IMessage = SparkieMsg & {
4
8
  metadata: {
@@ -80,3 +84,20 @@ export type FetchMessagesResponse = {
80
84
  export type SubmitQuestionEventDetail = {
81
85
  timestamp: number
82
86
  }
87
+
88
+ export type DirectMessagesServiceProps = {
89
+ GetAll: {
90
+ conversationId: string
91
+ before: number
92
+ limit: number
93
+ }
94
+ Create: {
95
+ conversationId: string
96
+ message: {
97
+ content: MessageContent
98
+ threadId?: string
99
+ parentId?: string
100
+ metadata?: Record<string, unknown>
101
+ }
102
+ }
103
+ }
@@ -0,0 +1 @@
1
+ export * from './use-init-sparkie'
@@ -0,0 +1,2 @@
1
+ export * from './use-init-sparkie'
2
+ export { default as useInitSparkie } from './use-init-sparkie'
@@ -0,0 +1,39 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+
3
+ import { useWidgetSettingsAtomValue } from '@/src/modules/widget'
4
+ import { SparkieService } from '../..'
5
+
6
+ function useInitSparkie() {
7
+ const [isSuccess, setIsSuccess] = useState(false)
8
+ const settings = useWidgetSettingsAtomValue()
9
+
10
+ const init = useCallback(async () => {
11
+ if (!settings?.hotmartToken) return
12
+
13
+ try {
14
+ await SparkieService.initSparkie({
15
+ token: settings?.hotmartToken,
16
+ skipPresenceSetup: true,
17
+ retryOptions: {
18
+ maxRetries: 5,
19
+ retryDelay: 2000,
20
+ backoffMultiplier: 1.5
21
+ }
22
+ })
23
+ await SparkieService.ensureInitialized()
24
+ setIsSuccess(true)
25
+ } catch {
26
+ setIsSuccess(false)
27
+ // TODO: Create Error PAGE and setTab
28
+ // setTab('information')
29
+ }
30
+ }, [settings?.hotmartToken])
31
+
32
+ useEffect(() => {
33
+ void init()
34
+ }, [init])
35
+
36
+ return isSuccess
37
+ }
38
+
39
+ export default useInitSparkie
@@ -0,0 +1,13 @@
1
+ import { DotLottieReact } from '@lottiefiles/dotlottie-react'
2
+
3
+ const AVATAR_ANIMATION_URL = `${process.env.STATIC_URL}/tutor/tutor_sparkle.lottie`
4
+
5
+ const AvatarAnimation = () => {
6
+ return (
7
+ <div className='flex h-11 w-11 items-center justify-center rounded-lg bg-ai-chat-response'>
8
+ <DotLottieReact src={AVATAR_ANIMATION_URL} loop autoplay className='h-auto w-full' />
9
+ </div>
10
+ )
11
+ }
12
+
13
+ export default AvatarAnimation
@@ -0,0 +1,2 @@
1
+ export * from './avatar-animation'
2
+ export { default as AvatarAnimation } from './avatar-animation'
@@ -1,4 +1,4 @@
1
- import { lazy, useEffect, useMemo, useRef } from 'react'
1
+ import { useEffect, useMemo, useRef } from 'react'
2
2
  import { useInfiniteQuery } from '@tanstack/react-query'
3
3
 
4
4
  import { isTextEmpty } from '@/src/lib/utils/is-text-empty'
@@ -15,10 +15,6 @@ import {
15
15
  import { WidgetHeader } from '../header'
16
16
  import { PageLayout } from '../page-layout'
17
17
 
18
- const MessageItemError = lazy(
19
- () => import('@/src/modules/messages/components/message-item-error/message-item-error')
20
- )
21
-
22
18
  function ChatPage() {
23
19
  const chatInputRef = useRef<HTMLTextAreaElement>(null)
24
20
  const scrollerRef = useRef<HTMLDivElement>(null)
@@ -84,13 +80,13 @@ function ChatPage() {
84
80
  await messagesQuery.fetchNextPage()
85
81
  }}
86
82
  showButton={messagesQuery.hasNextPage}
87
- loading={messagesQuery.isFetchingNextPage}>
83
+ loading={messagesQuery.isFetchingNextPage}
84
+ error={{
85
+ show: messagesQuery.isError,
86
+ message: messagesQuery.error?.message ?? '',
87
+ retry: () => void messagesQuery.refetch()
88
+ }}>
88
89
  {messagesQuery.data && <MessagesList messagesMap={messagesQuery.data} />}
89
- <MessageItemError
90
- show={messagesQuery.isError}
91
- message={`❌ Error loading messages: ${messagesQuery.error?.message ?? ''}`}
92
- retry={() => void messagesQuery.refetch()}
93
- />
94
90
  </MessagesContainer>
95
91
  </PageLayout>
96
92
  )
@@ -1,24 +1,16 @@
1
- import { useEffect } from 'react'
2
-
3
1
  import { useSubscribeMessageReceivedEvent } from '@/src/modules/messages/hooks'
4
2
  import { useSubscribeThreadClosedEvent } from '@/src/modules/thread/hooks'
5
3
  import { useListenToVisibilityEvents } from '../../hooks'
6
4
  import { useWidgetTabsAtom } from '../../store'
7
5
  import { WIDGET_TABS } from '../constants'
8
6
 
9
- function WidgetContainer({ completeSetup = false }: { completeSetup?: boolean }) {
10
- const [widgetTabs, setTab] = useWidgetTabsAtom()
7
+ function WidgetContainer() {
8
+ const [widgetTabs] = useWidgetTabsAtom()
11
9
 
12
10
  useSubscribeMessageReceivedEvent()
13
11
  useSubscribeThreadClosedEvent()
14
12
  useListenToVisibilityEvents()
15
13
 
16
- useEffect(() => {
17
- if (completeSetup) {
18
- setTab('starter')
19
- }
20
- }, [completeSetup, setTab])
21
-
22
14
  return (
23
15
  <div className='flex h-full flex-col items-center justify-stretch overflow-hidden'>
24
16
  {WIDGET_TABS[widgetTabs.currentTab]}
@@ -1,4 +1,5 @@
1
1
  export * from './ai-avatar'
2
+ export * from './avatar-animation'
2
3
  export * from './chat-page'
3
4
  export * from './container'
4
5
  export * from './greetings-card'
@@ -8,6 +8,10 @@ vi.mock('@/src/modules/messages/hooks', () => ({
8
8
  useSendTextMessage: vi.fn()
9
9
  }))
10
10
 
11
+ vi.mock('@/src/modules/sparkie/hooks/use-init-sparkie', () => ({
12
+ useInitSparkie: vi.fn(() => true)
13
+ }))
14
+
11
15
  describe('WidgetStarterPage', () => {
12
16
  const useSendTextMessageMock = { mutate: vi.fn() }
13
17
 
@@ -4,12 +4,14 @@ import clsx from 'clsx'
4
4
  import type { MouseEventHandler } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
6
 
7
- import { Button } from '@/src/lib/components'
7
+ import { Button, HorizontalDraggableScroll } from '@/src/lib/components'
8
+ import type { ValidIconNames } from '@/src/lib/components/icons/icon-names'
8
9
  import { useRefEventListener } from '@/src/lib/hooks'
9
10
  import { ChatInput, useChatInputValueAtom } from '@/src/modules/messages/components'
10
11
  import { getAllMessagesQuery, useSendTextMessage } from '@/src/modules/messages/hooks'
11
12
  import { useMessagesMaxCount } from '@/src/modules/messages/store'
12
13
  import { useGetProfile } from '@/src/modules/profile'
14
+ import { useInitSparkie } from '@/src/modules/sparkie/hooks/use-init-sparkie'
13
15
  import { useWidgetSettingsAtom, useWidgetTabsAtom } from '../../store'
14
16
  import { GreetingsCard } from '../greetings-card'
15
17
  import { WidgetHeader } from '../header'
@@ -28,6 +30,7 @@ function WidgetStarterPage() {
28
30
  const limit = useMessagesMaxCount()
29
31
  const queryClient = useQueryClient()
30
32
  const name = settings?.tutorName ?? t('general.name')
33
+ const isSparkieReady = useInitSparkie()
31
34
 
32
35
  useRefEventListener<HTMLTextAreaElement>({
33
36
  config: {
@@ -74,9 +77,19 @@ function WidgetStarterPage() {
74
77
  [conversationId, limit, profileId]
75
78
  )
76
79
 
80
+ const headerBtns = useMemo(() => {
81
+ const btnList = ['info', 'close']
82
+
83
+ if (!isSparkieReady) return btnList
84
+
85
+ return btnList.concat(['archive'])
86
+ }, [isSparkieReady]) as ValidIconNames[]
87
+
77
88
  useEffect(() => {
89
+ if (!conversationId || !profileId) return
90
+
78
91
  void queryClient.prefetchInfiniteQuery(messagesQueryConfig)
79
- }, [messagesQueryConfig, queryClient])
92
+ }, [conversationId, messagesQueryConfig, profileId, queryClient])
80
93
 
81
94
  return (
82
95
  <PageLayout
@@ -85,7 +98,7 @@ function WidgetStarterPage() {
85
98
  name='new-chat-msg-input'
86
99
  ref={chatInputRef}
87
100
  onSend={handleSend}
88
- buttonDisabled={!chatInputValue.trim()}
101
+ buttonDisabled={!chatInputValue.trim() || !isSparkieReady}
89
102
  />
90
103
  }>
91
104
  <div className='grid-areas-[a_b] grid h-full grid-cols-1 grid-rows-[1fr_auto]'>
@@ -93,7 +106,7 @@ function WidgetStarterPage() {
93
106
  className={clsx('grid-area-[a] flex min-h-0 flex-col max-md:p-[1.125rem] md:p-5', {
94
107
  [styles.bg]: settings?.config?.theme === 'dark'
95
108
  })}>
96
- <WidgetHeader enabledButtons={['archive', 'info', 'close']} tutorName={name} />
109
+ <WidgetHeader enabledButtons={headerBtns} tutorName={name} />
97
110
 
98
111
  <div className='my-auto'>
99
112
  <GreetingsCard
@@ -103,22 +116,24 @@ function WidgetStarterPage() {
103
116
  />
104
117
  </div>
105
118
  </div>
106
- <div className='grid-area-[b] mx-5 my-6 flex flex-shrink-0 snap-x snap-mandatory gap-2 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
119
+ <HorizontalDraggableScroll className='grid-area-[b] mx-5 my-6 flex flex-shrink-0 snap-x snap-mandatory gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
107
120
  <Button
108
121
  variant='gradient-outline'
109
122
  className='shrink-0 snap-end text-sm'
110
- onClick={handleAskQuestion}>
123
+ onClick={handleAskQuestion}
124
+ loading={!isSparkieReady}>
111
125
  <span>🤖 </span>
112
126
  {t('starter_page.what_does_tutor_do')}
113
127
  </Button>
114
128
  <Button
115
129
  variant='gradient-outline'
116
130
  className='shrink-0 snap-end text-sm'
117
- onClick={handleAskQuestion}>
131
+ onClick={handleAskQuestion}
132
+ loading={!isSparkieReady}>
118
133
  <span>📝 </span>
119
134
  {t('starter_page.wanna_summary')}
120
135
  </Button>
121
- </div>
136
+ </HorizontalDraggableScroll>
122
137
  </div>
123
138
  </PageLayout>
124
139
  )
@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'
3
3
  import { DataHubStore } from '@/src/config/datahub'
4
4
  import { initDayjs } from '@/src/config/dayjs'
5
5
  import { initAxios } from '@/src/config/request/api'
6
- import { SparkieService } from '@/src/modules/sparkie'
7
6
  import type { WidgetSettingProps } from '@/src/types'
8
7
  import { TutorWidgetEvents } from '../../events'
9
8
 
@@ -18,16 +17,6 @@ const init = async (settings: WidgetSettingProps) => {
18
17
  sessionId: settings.sessionId,
19
18
  userId: Number(settings.userId)
20
19
  })
21
- await SparkieService.initSparkie({
22
- token: settings?.hotmartToken,
23
- skipPresenceSetup: true,
24
- retryOptions: {
25
- maxRetries: 5,
26
- retryDelay: 2000,
27
- backoffMultiplier: 1.5
28
- }
29
- })
30
- await SparkieService.ensureInitialized()
31
20
  TutorWidgetEvents['tutor-app-widget-loaded'].dispatch()
32
21
  } catch (error) {
33
22
  console.error(error)
@@ -8,8 +8,8 @@ export type WidgetTabsProps = {
8
8
  }
9
9
 
10
10
  const INITIAL_PROPS: WidgetTabsProps = {
11
- currentTab: 'loading',
12
- history: new Set(['loading'])
11
+ currentTab: 'starter',
12
+ history: new Set(['starter'])
13
13
  }
14
14
 
15
15
  export const widgetTabsAtom = atom<WidgetTabsProps>(INITIAL_PROPS)