app-tutor-ai-consumer 1.33.0 → 1.34.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 (122) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/config/vitest/__mocks__/sparkie.tsx +1 -1
  3. package/package.json +1 -1
  4. package/src/bootstrap.ts +40 -0
  5. package/src/config/tests/handlers.ts +5 -4
  6. package/src/config/theme/init-theme.ts +11 -5
  7. package/src/index.tsx +22 -12
  8. package/src/lib/components/dropdown-actions/dropdown-actions.tsx +87 -0
  9. package/src/lib/components/dropdown-actions/dropdownActions.builder.ts +58 -0
  10. package/src/lib/components/dropdown-actions/dropdownActions.spec.tsx +76 -0
  11. package/src/lib/components/dropdown-actions/index.ts +1 -0
  12. package/src/lib/components/dropdown-actions/types.ts +16 -0
  13. package/src/lib/components/errors/generic/generic-error.tsx +11 -8
  14. package/src/lib/components/icons/document.svg +3 -0
  15. package/src/lib/components/icons/file.svg +3 -0
  16. package/src/lib/components/icons/icon-names.d.ts +8 -0
  17. package/src/lib/components/icons/image.svg +3 -0
  18. package/src/lib/components/icons/pdf.svg +3 -0
  19. package/src/lib/components/icons/plus.svg +3 -0
  20. package/src/lib/components/icons/retry.svg +3 -0
  21. package/src/lib/components/icons/spreadsheet.svg +3 -0
  22. package/src/lib/components/icons/tutor-logo.svg +9 -0
  23. package/src/lib/components/index.ts +1 -0
  24. package/src/lib/components/markdownrenderer/markdownrenderer.tsx +1 -3
  25. package/src/lib/hooks/index.ts +1 -0
  26. package/src/lib/hooks/use-chat-file-upload/constants.ts +11 -0
  27. package/src/lib/hooks/use-chat-file-upload/index.ts +1 -0
  28. package/src/lib/hooks/use-chat-file-upload/types.ts +14 -0
  29. package/src/lib/hooks/use-chat-file-upload/use-chat-file-upload.spec.ts +59 -0
  30. package/src/lib/hooks/use-chat-file-upload/use-chat-file-upload.ts +28 -0
  31. package/src/lib/hooks/use-click-outside/index.ts +1 -0
  32. package/src/lib/hooks/use-click-outside/use-click-outside.tsx +23 -0
  33. package/src/lib/hooks/use-click-outside/useClickOutside.spec.ts +102 -0
  34. package/src/lib/utils/index.ts +1 -0
  35. package/src/lib/utils/is-theme-dark.ts +21 -0
  36. package/src/main/hooks/use-initial-store/index.ts +1 -0
  37. package/src/main/hooks/use-initial-store/use-initial-store.tsx +64 -0
  38. package/src/main/hooks/use-initial-tab/index.ts +1 -0
  39. package/src/main/hooks/use-initial-tab/use-initial-tab.tsx +84 -0
  40. package/src/main/index.ts +1 -0
  41. package/src/main/main-content.tsx +14 -0
  42. package/src/main/main-wrapper.tsx +16 -0
  43. package/src/main/main.spec.tsx +5 -3
  44. package/src/main/main.tsx +7 -16
  45. package/src/main/types.ts +5 -0
  46. package/src/modules/global-providers/global-providers.tsx +1 -15
  47. package/src/modules/messages/__tests__/signed-urls.builder.ts +42 -0
  48. package/src/modules/messages/components/chat-file-preview/chat-file-preview.builder.ts +121 -0
  49. package/src/modules/messages/components/chat-file-preview/chat-file-preview.spec.tsx +107 -0
  50. package/src/modules/messages/components/chat-file-preview/chat-file-preview.tsx +45 -0
  51. package/src/modules/messages/components/chat-file-preview/components/close-button/close-button.tsx +31 -0
  52. package/src/modules/messages/components/chat-file-preview/components/close-button/index.ts +1 -0
  53. package/src/modules/messages/components/chat-file-preview/components/close-button/types.ts +4 -0
  54. package/src/modules/messages/components/chat-file-preview/components/document-preview/document-preview.tsx +63 -0
  55. package/src/modules/messages/components/chat-file-preview/components/document-preview/index.ts +1 -0
  56. package/src/modules/messages/components/chat-file-preview/components/document-preview/types.ts +10 -0
  57. package/src/modules/messages/components/chat-file-preview/components/error-preview/error-preview.tsx +37 -0
  58. package/src/modules/messages/components/chat-file-preview/components/error-preview/index.ts +1 -0
  59. package/src/modules/messages/components/chat-file-preview/components/error-preview/types.ts +4 -0
  60. package/src/modules/messages/components/chat-file-preview/components/image-preview/image-preview.tsx +32 -0
  61. package/src/modules/messages/components/chat-file-preview/components/image-preview/index.ts +1 -0
  62. package/src/modules/messages/components/chat-file-preview/components/image-preview/types.ts +6 -0
  63. package/src/modules/messages/components/chat-file-preview/constants.ts +22 -0
  64. package/src/modules/messages/components/chat-file-preview/index.ts +1 -0
  65. package/src/modules/messages/components/chat-file-preview/types.ts +13 -0
  66. package/src/modules/messages/components/chat-file-preview/utils.spec.ts +38 -0
  67. package/src/modules/messages/components/chat-file-preview/utils.ts +13 -0
  68. package/src/modules/messages/components/chat-file-uploader/chat-file-uploader.builder.ts +19 -0
  69. package/src/modules/messages/components/chat-file-uploader/chat-file-uploader.spec.tsx +58 -0
  70. package/src/modules/messages/components/chat-file-uploader/chat-file-uploader.tsx +80 -0
  71. package/src/modules/messages/components/chat-file-uploader/index.ts +1 -0
  72. package/src/modules/messages/components/chat-file-uploader/types.ts +4 -0
  73. package/src/modules/messages/components/chat-input/chat-input.tsx +1 -1
  74. package/src/modules/messages/components/index.ts +1 -0
  75. package/src/modules/messages/components/message-item/message-item.tsx +1 -2
  76. package/src/modules/messages/constants.ts +2 -1
  77. package/src/modules/messages/hooks/use-get-signed-urls/index.ts +1 -0
  78. package/src/modules/messages/hooks/use-get-signed-urls/use-get-signed-urls.spec.tsx +27 -0
  79. package/src/modules/messages/hooks/use-get-signed-urls/use-get-signed-urls.tsx +38 -0
  80. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +1 -2
  81. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +3 -8
  82. package/src/modules/messages/hooks/use-prefetch-messages/index.ts +1 -0
  83. package/src/modules/messages/hooks/use-prefetch-messages/use-prefetch-messages.tsx +28 -0
  84. package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +54 -119
  85. package/src/modules/messages/hooks/use-suspense-messages/index.ts +2 -0
  86. package/src/modules/messages/hooks/use-suspense-messages/types.ts +4 -0
  87. package/src/modules/messages/hooks/use-suspense-messages/use-suspense-messages.tsx +21 -0
  88. package/src/modules/messages/service.direct.ts +18 -0
  89. package/src/modules/messages/service.ts +1 -2
  90. package/src/modules/messages/store/messages-max-count.atom.ts +2 -2
  91. package/src/modules/messages/types.ts +14 -0
  92. package/src/modules/profile/hooks/use-get-profile/use-get-profile.tsx +7 -6
  93. package/src/modules/sparkie/__tests__/sparkie.mock.ts +1 -4
  94. package/src/modules/sparkie/hooks/use-init-sparkie/use-init-sparkie.tsx +30 -38
  95. package/src/modules/sparkie/service.ts +2 -1
  96. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +20 -1
  97. package/src/modules/widget/components/ai-disclaimer/ai-disclaimer.tsx +19 -0
  98. package/src/modules/widget/components/ai-disclaimer/index.ts +1 -0
  99. package/src/modules/widget/components/chat-page/chat-page.tsx +30 -71
  100. package/src/modules/widget/components/container/container.tsx +14 -0
  101. package/src/modules/widget/components/greetings-card/greetings-card.tsx +9 -2
  102. package/src/modules/widget/components/loading-page/loading-page.tsx +9 -15
  103. package/src/modules/widget/components/starter-page/starter-page-actions/starter-page-actions.spec.tsx +4 -4
  104. package/src/modules/widget/components/starter-page/starter-page-actions/starter-page-actions.tsx +3 -2
  105. package/src/modules/widget/components/starter-page/starter-page-content/starter-page-content.spec.tsx +0 -46
  106. package/src/modules/widget/components/starter-page/starter-page-content/starter-page-content.tsx +1 -30
  107. package/src/modules/widget/components/starter-page/starter-page-header/starter-page-header.spec.tsx +8 -4
  108. package/src/modules/widget/components/starter-page/starter-page-header/starter-page-header.tsx +15 -13
  109. package/src/modules/widget/components/starter-page/starter-page.spec.tsx +13 -3
  110. package/src/modules/widget/components/starter-page/starter-page.tsx +22 -87
  111. package/src/modules/widget/hooks/index.ts +0 -1
  112. package/src/modules/widget/hooks/use-listen-to-theme-change-event/use-listen-to-theme-change-event.tsx +8 -6
  113. package/src/modules/widget/store/create-store.ts +7 -0
  114. package/src/modules/widget/store/index.ts +1 -0
  115. package/src/modules/widget/store/widget-settings-config.atom.ts +1 -6
  116. package/src/modules/widget/store/widget-tabs.atom.ts +18 -37
  117. package/src/types.ts +1 -0
  118. package/src/wrapper.tsx +39 -19
  119. package/src/lib/hooks/use-response-timeout/index.ts +0 -1
  120. package/src/lib/hooks/use-response-timeout/use-response-timeout.tsx +0 -42
  121. package/src/modules/widget/hooks/use-init-widget/index.ts +0 -1
  122. package/src/modules/widget/hooks/use-init-widget/use-init-widget.tsx +0 -56
@@ -0,0 +1,121 @@
1
+ import { chance } from '@/src/config/tests'
2
+
3
+ import type { FilePreviewProps, FilePreviewTypes } from './types'
4
+
5
+ class ChatFilePreviewBuilder implements FilePreviewProps {
6
+ name: string
7
+ size: number
8
+ type: FilePreviewTypes
9
+ showCloseButton: boolean
10
+ hasError: boolean
11
+ isLoading: boolean
12
+ imageUrl?: string
13
+ onClose?: () => void
14
+
15
+ constructor() {
16
+ this.name = chance.word() + '.pdf'
17
+ this.size = chance.integer({ min: 1000, max: 10000000 })
18
+ this.type = 'pdf'
19
+ this.showCloseButton = false
20
+ this.hasError = false
21
+ this.isLoading = false
22
+ this.onClose = vi.fn()
23
+ }
24
+
25
+ withName(name: typeof this.name) {
26
+ this.name = name
27
+
28
+ return this
29
+ }
30
+
31
+ withSize(size: typeof this.size) {
32
+ this.size = size
33
+
34
+ return this
35
+ }
36
+
37
+ withType(type: typeof this.type) {
38
+ this.type = type
39
+
40
+ return this
41
+ }
42
+
43
+ withShowCloseButton(showCloseButton: typeof this.showCloseButton) {
44
+ this.showCloseButton = showCloseButton
45
+
46
+ return this
47
+ }
48
+
49
+ withImageUrl(imageUrl: typeof this.imageUrl) {
50
+ this.imageUrl = imageUrl
51
+
52
+ return this
53
+ }
54
+
55
+ withHasError(hasError: typeof this.hasError) {
56
+ this.hasError = hasError
57
+
58
+ return this
59
+ }
60
+
61
+ withOnClose(onClose: typeof this.onClose) {
62
+ this.onClose = onClose
63
+
64
+ return this
65
+ }
66
+
67
+ withIsLoading(isLoading: typeof this.isLoading) {
68
+ this.isLoading = isLoading
69
+
70
+ return this
71
+ }
72
+
73
+ asPdf() {
74
+ this.type = 'pdf'
75
+ this.name = chance.word() + '.pdf'
76
+
77
+ return this
78
+ }
79
+
80
+ asDocument() {
81
+ this.type = 'document'
82
+ this.name = chance.word() + '.docx'
83
+
84
+ return this
85
+ }
86
+
87
+ asSpreadsheet() {
88
+ this.type = 'spreadsheet'
89
+ this.name = chance.word() + '.xlsx'
90
+
91
+ return this
92
+ }
93
+
94
+ asImage() {
95
+ this.type = 'image'
96
+ this.name = chance.word() + '.jpg'
97
+ this.imageUrl = chance.url()
98
+
99
+ return this
100
+ }
101
+
102
+ withError() {
103
+ this.hasError = true
104
+
105
+ return this
106
+ }
107
+
108
+ withLoading() {
109
+ this.isLoading = true
110
+
111
+ return this
112
+ }
113
+
114
+ withCloseButton() {
115
+ this.showCloseButton = true
116
+
117
+ return this
118
+ }
119
+ }
120
+
121
+ export default ChatFilePreviewBuilder
@@ -0,0 +1,107 @@
1
+ import { render, screen } from '@/src/config/tests'
2
+ import { useMediaQuery } from '@/src/lib/hooks'
3
+ import * as WidgetStore from '@/src/modules/widget/store'
4
+
5
+ import ChatFilePreview from './chat-file-preview'
6
+ import ChatFilePreviewBuilder from './chat-file-preview.builder'
7
+
8
+ vi.mock('@/src/lib/hooks', async () => ({
9
+ ...(await vi.importActual('@/src/lib/hooks')),
10
+ useMediaQuery: vi.fn()
11
+ }))
12
+
13
+ describe('ChatFilePreview', () => {
14
+ const mockSettings = {
15
+ config: {
16
+ theme: 'dark'
17
+ }
18
+ }
19
+
20
+ const defaultProps = new ChatFilePreviewBuilder()
21
+
22
+ const renderComponent = (props = defaultProps) => render(<ChatFilePreview {...props} />)
23
+
24
+ beforeEach(() => {
25
+ vi.spyOn(WidgetStore, 'useWidgetSettingsAtom').mockReturnValue([mockSettings, vi.fn()] as never)
26
+ vi.mocked(useMediaQuery).mockReturnValue(false)
27
+ })
28
+
29
+ afterEach(() => {
30
+ vi.clearAllMocks()
31
+ })
32
+
33
+ describe('Error state', () => {
34
+ it('should render error message when hasError is true', () => {
35
+ renderComponent(new ChatFilePreviewBuilder().withError())
36
+
37
+ expect(screen.getByText(/send_message.file_upload.error.load_file/i)).toBeInTheDocument()
38
+ expect(screen.getByText(/general.buttons.try_again/i)).toBeInTheDocument()
39
+ })
40
+
41
+ it('should render spinner when hasError and isLoading are both true', () => {
42
+ renderComponent(new ChatFilePreviewBuilder().withError().withLoading())
43
+
44
+ expect(screen.queryByText(/general.buttons.try_again/i)).not.toBeInTheDocument()
45
+ })
46
+ })
47
+
48
+ describe('Image type', () => {
49
+ it('should render image when type is image', () => {
50
+ const builder = new ChatFilePreviewBuilder().asImage()
51
+ renderComponent(builder)
52
+
53
+ const image = screen.getByRole('img', { name: builder.name })
54
+ expect(image).toHaveAttribute('src', builder.imageUrl)
55
+ })
56
+ })
57
+
58
+ describe('Document types (PDF, document, spreadsheet)', () => {
59
+ it('should render PDF file with correct icon and label', () => {
60
+ renderComponent()
61
+
62
+ expect(screen.getByText(defaultProps.name)).toBeInTheDocument()
63
+ expect(screen.getByText(/file_upload.file_type.pdf/i)).toBeInTheDocument()
64
+ })
65
+
66
+ it('should render document file with correct icon and label', () => {
67
+ const builder = new ChatFilePreviewBuilder().asDocument().withName('file.docx')
68
+ renderComponent(builder)
69
+
70
+ expect(screen.getByText('file.docx')).toBeInTheDocument()
71
+ expect(screen.getByText(/file_upload.file_type.document/i)).toBeInTheDocument()
72
+ })
73
+
74
+ it('should render spreadsheet file with correct icon and label', () => {
75
+ const builder = new ChatFilePreviewBuilder().asSpreadsheet().withName('data.xlsx')
76
+ renderComponent(builder)
77
+
78
+ expect(screen.getByText('data.xlsx')).toBeInTheDocument()
79
+ expect(screen.getByText(/file_upload.file_type.spreadsheet/i)).toBeInTheDocument()
80
+ })
81
+
82
+ it('should render spinner when isLoading is true', () => {
83
+ renderComponent(new ChatFilePreviewBuilder().withLoading())
84
+
85
+ expect(screen.queryByText(/file_upload.file_type/i)).toBeInTheDocument()
86
+ })
87
+ })
88
+
89
+ describe('Close button behavior', () => {
90
+ it('should call onClose when close button is clicked', async () => {
91
+ const props = new ChatFilePreviewBuilder().withCloseButton()
92
+ const { user } = renderComponent(props)
93
+
94
+ await user.click(screen.getByRole('button', { name: 'general.buttons.remove_attachment' }))
95
+
96
+ expect(props.onClose).toHaveBeenCalledTimes(1)
97
+ })
98
+
99
+ it('should not render close button when showCloseButton is false', () => {
100
+ renderComponent()
101
+
102
+ expect(
103
+ screen.queryByRole('button', { name: 'general.buttons.remove_attachment' })
104
+ ).not.toBeInTheDocument()
105
+ })
106
+ })
107
+ })
@@ -0,0 +1,45 @@
1
+ import { DocumentPreview } from './components/document-preview'
2
+ import { ErrorPreview } from './components/error-preview'
3
+ import { ImagePreview } from './components/image-preview'
4
+ import type { FilePreviewProps } from './types'
5
+
6
+ function ChatFilePreview({
7
+ name,
8
+ size,
9
+ type,
10
+ showCloseButton = false,
11
+ onClose,
12
+ imageUrl,
13
+ onRetry,
14
+ hasError = false,
15
+ isLoading = false
16
+ }: FilePreviewProps) {
17
+ if (hasError) {
18
+ return <ErrorPreview isLoading={isLoading} onRetry={onRetry} />
19
+ }
20
+
21
+ if (type === 'image') {
22
+ return (
23
+ <ImagePreview
24
+ imageUrl={imageUrl}
25
+ name={name}
26
+ isLoading={isLoading}
27
+ showCloseButton={showCloseButton}
28
+ onClose={onClose}
29
+ />
30
+ )
31
+ }
32
+
33
+ return (
34
+ <DocumentPreview
35
+ name={name}
36
+ size={size}
37
+ type={type}
38
+ isLoading={isLoading}
39
+ showCloseButton={showCloseButton}
40
+ onClose={onClose}
41
+ />
42
+ )
43
+ }
44
+
45
+ export default ChatFilePreview
@@ -0,0 +1,31 @@
1
+ import clsx from 'clsx'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Icon } from '@/src/lib/components/icons'
5
+
6
+ import type { CloseButtonProps } from './types'
7
+
8
+ export default function CloseButton({ onClick, variant = 'overlay' }: CloseButtonProps) {
9
+ const { t } = useTranslation()
10
+ const baseClasses =
11
+ 'flex items-center justify-center transition-colors duration-150 outline-none focus:outline-none'
12
+ const variantClasses = {
13
+ overlay: 'absolute right-1 top-1 h-6 w-6 rounded-full bg-white shadow-sm hover:bg-neutral-100',
14
+ inline: 'ml-7 shrink-0 cursor-pointer text-neutral-900 hover:text-neutral-500'
15
+ }
16
+
17
+ return (
18
+ <button
19
+ onClick={onClick}
20
+ className={clsx(baseClasses, variantClasses[variant])}
21
+ aria-label={t('general.buttons.remove_attachment')}>
22
+ <Icon
23
+ name='close'
24
+ className={clsx('h-3 w-3', {
25
+ 'text-neutral-900': variant === 'overlay',
26
+ 'mr-3': variant === 'inline'
27
+ })}
28
+ />
29
+ </button>
30
+ )
31
+ }
@@ -0,0 +1 @@
1
+ export { default as CloseButton } from './close-button'
@@ -0,0 +1,4 @@
1
+ export type CloseButtonProps = {
2
+ onClick?: () => void
3
+ variant?: 'overlay' | 'inline'
4
+ }
@@ -0,0 +1,63 @@
1
+ import clsx from 'clsx'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Icon, Spinner } from '@/src/lib/components'
5
+ import { useMediaQuery } from '@/src/lib/hooks'
6
+ import { useWidgetSettingsAtom } from '@/src/modules/widget/store'
7
+ import { CHAT_FILE_PREVIEW_CONFIG } from '../../constants'
8
+ import { formatFileSize } from '../../utils'
9
+ import { CloseButton } from '../close-button'
10
+
11
+ import type { DocumentPreviewProps } from './types'
12
+
13
+ export default function DocumentPreview({
14
+ name,
15
+ size,
16
+ type,
17
+ isLoading,
18
+ showCloseButton,
19
+ onClose
20
+ }: DocumentPreviewProps) {
21
+ const { t } = useTranslation()
22
+ const [settings] = useWidgetSettingsAtom()
23
+ const isMobile = useMediaQuery({ maxSize: 'md' })
24
+ const formattedSize = formatFileSize(size)
25
+ const isDarkMode = settings?.config?.theme === 'dark'
26
+ const config = CHAT_FILE_PREVIEW_CONFIG[type]
27
+
28
+ return (
29
+ <div
30
+ className={clsx(
31
+ 'flex max-w-[345px] items-center justify-between rounded-lg border border-neutral-200 p-3',
32
+ {
33
+ 'bg-neutral-100': isDarkMode,
34
+ 'bg-white': !isDarkMode,
35
+ 'w-full max-w-full': isMobile && showCloseButton
36
+ }
37
+ )}>
38
+ <div className='flex min-w-0 flex-1 items-center gap-3 overflow-hidden'>
39
+ <div
40
+ className='flex h-10 w-10 shrink-0 items-center justify-center rounded-md'
41
+ style={{ backgroundColor: config.bgColor }}>
42
+ {isLoading ? (
43
+ <Spinner className='h-[18px] w-[18px] text-white' />
44
+ ) : (
45
+ <Icon name={config.icon} className='h-[18px] w-[18px] text-white' />
46
+ )}
47
+ </div>
48
+ <div className='min-w-0 flex-1 overflow-hidden'>
49
+ <p className='mb-0 mr-3 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-neutral-900'>
50
+ {name}
51
+ </p>
52
+ <div className='flex items-center gap-1 text-xs text-neutral-600'>
53
+ <span className='mb-0'>{t(config.label)}</span>
54
+ <span className='mb-0'>-</span>
55
+ <span className='mb-0'>{formattedSize}</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ {showCloseButton && <CloseButton onClick={onClose} variant='inline' />}
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1 @@
1
+ export { default as DocumentPreview } from './document-preview'
@@ -0,0 +1,10 @@
1
+ import type { FilePreviewProps } from '../../types'
2
+
3
+ export type DocumentPreviewProps = {
4
+ name: string
5
+ size: number
6
+ type: Exclude<FilePreviewProps['type'], 'image'>
7
+ isLoading: boolean
8
+ showCloseButton: boolean
9
+ onClose?: () => void
10
+ }
@@ -0,0 +1,37 @@
1
+ import clsx from 'clsx'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Icon, Spinner } from '@/src/lib/components'
5
+ import { useWidgetSettingsAtom } from '@/src/modules/widget/store'
6
+
7
+ import type { ErrorPreviewProps } from './types'
8
+
9
+ export default function ErrorPreview({ isLoading, onRetry }: ErrorPreviewProps) {
10
+ const { t } = useTranslation()
11
+ const [settings] = useWidgetSettingsAtom()
12
+ const isDarkMode = settings?.config?.theme === 'dark'
13
+
14
+ return (
15
+ <div
16
+ className={clsx('w-fit rounded-lg border border-neutral-200 px-3 py-2', {
17
+ 'bg-neutral-100': isDarkMode,
18
+ 'bg-white': !isDarkMode
19
+ })}>
20
+ {isLoading ? (
21
+ <Spinner className='h-[18px] w-[18px] text-neutral-900' />
22
+ ) : (
23
+ <div className='flex h-full w-full flex-col items-start'>
24
+ <p className='mb-1 text-xs text-red-700'>
25
+ {t('send_message.file_upload.error.load_file')}
26
+ </p>
27
+ <button
28
+ onClick={onRetry}
29
+ className='flex items-center gap-1 rounded border border-neutral-300 px-2 py-1 text-xs text-neutral-900 hover:bg-neutral-200'>
30
+ <Icon name='retry' className='h-4 w-3' />
31
+ {t('general.buttons.try_again')}
32
+ </button>
33
+ </div>
34
+ )}
35
+ </div>
36
+ )
37
+ }
@@ -0,0 +1 @@
1
+ export { default as ErrorPreview } from './error-preview'
@@ -0,0 +1,4 @@
1
+ export type ErrorPreviewProps = {
2
+ isLoading: boolean
3
+ onRetry?: () => void
4
+ }
@@ -0,0 +1,32 @@
1
+ import clsx from 'clsx'
2
+
3
+ import { Spinner } from '@/src/lib/components/spinner'
4
+ import { CloseButton } from '../close-button'
5
+
6
+ import type { ImagePreviewProps } from './types'
7
+
8
+ export default function ImagePreview({
9
+ imageUrl,
10
+ name,
11
+ isLoading,
12
+ showCloseButton,
13
+ onClose
14
+ }: ImagePreviewProps) {
15
+ return (
16
+ <div className='relative h-16 w-16'>
17
+ <img
18
+ src={imageUrl}
19
+ alt={name}
20
+ className={clsx('h-full w-full rounded-lg object-cover', {
21
+ 'backdrop-blur-sm': isLoading
22
+ })}
23
+ />
24
+ {isLoading && (
25
+ <div className='absolute inset-0 flex items-center justify-center rounded-lg bg-black/30 backdrop-blur-sm'>
26
+ <Spinner className='h-[18px] w-[18px] text-white' />
27
+ </div>
28
+ )}
29
+ {showCloseButton && !isLoading && <CloseButton onClick={onClose} variant='overlay' />}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1 @@
1
+ export { default as ImagePreview } from './image-preview'
@@ -0,0 +1,6 @@
1
+ import type { FilePreviewProps } from '../../types'
2
+
3
+ export type ImagePreviewProps = Pick<
4
+ FilePreviewProps,
5
+ 'imageUrl' | 'name' | 'isLoading' | 'showCloseButton' | 'onClose'
6
+ >
@@ -0,0 +1,22 @@
1
+ export const CHAT_FILE_PREVIEW_CONFIG = {
2
+ pdf: {
3
+ bgColor: '#D2444B',
4
+ label: 'send_message.file_upload.file_type.pdf',
5
+ icon: 'pdf'
6
+ },
7
+ document: {
8
+ bgColor: '#007EA8',
9
+ label: 'send_message.file_upload.file_type.document',
10
+ icon: 'document'
11
+ },
12
+ spreadsheet: {
13
+ bgColor: '#00992B',
14
+ label: 'send_message.file_upload.file_type.spreadsheet',
15
+ icon: 'spreadsheet'
16
+ }
17
+ } as const
18
+
19
+ export const FILE_SIZE_UNITS = {
20
+ KB: 1024,
21
+ MB: 1024 * 1024
22
+ } as const
@@ -0,0 +1 @@
1
+ export { default as ChatFilePreview } from './chat-file-preview'
@@ -0,0 +1,13 @@
1
+ export type FilePreviewTypes = 'pdf' | 'document' | 'spreadsheet' | 'image'
2
+
3
+ export type FilePreviewProps = {
4
+ name: string
5
+ size: number
6
+ type: FilePreviewTypes
7
+ showCloseButton?: boolean
8
+ imageUrl?: string
9
+ hasError?: boolean
10
+ onRetry?: () => void
11
+ onClose?: () => void
12
+ isLoading?: boolean
13
+ }
@@ -0,0 +1,38 @@
1
+ import { formatFileSize } from './utils'
2
+
3
+ describe('formatFileSize', () => {
4
+ it('should format bytes correctly when size is less than 1KB', () => {
5
+ expect(formatFileSize(0)).toBe('0B')
6
+ expect(formatFileSize(100)).toBe('100B')
7
+ expect(formatFileSize(512)).toBe('512B')
8
+ expect(formatFileSize(1023)).toBe('1023B')
9
+ })
10
+
11
+ it('should format kilobytes correctly when size is between 1KB and 1MB', () => {
12
+ expect(formatFileSize(1024)).toBe('1KB')
13
+ expect(formatFileSize(2048)).toBe('2KB')
14
+ expect(formatFileSize(1536)).toBe('1.5KB')
15
+ expect(formatFileSize(10240)).toBe('10KB')
16
+ expect(formatFileSize(102400)).toBe('100KB')
17
+ })
18
+
19
+ it('should format megabytes correctly when size is greater than or equal to 1MB', () => {
20
+ expect(formatFileSize(1048576)).toBe('1MB')
21
+ expect(formatFileSize(2097152)).toBe('2MB')
22
+ expect(formatFileSize(1572864)).toBe('1.5MB')
23
+ expect(formatFileSize(10485760)).toBe('10MB')
24
+ })
25
+
26
+ it('should round to one decimal place', () => {
27
+ expect(formatFileSize(1638)).toBe('1.6KB')
28
+ expect(formatFileSize(1740)).toBe('1.7KB')
29
+ expect(formatFileSize(1100000)).toBe('1MB')
30
+ expect(formatFileSize(1600000)).toBe('1.5MB')
31
+ })
32
+
33
+ it('should handle edge cases', () => {
34
+ expect(formatFileSize(1025)).toBe('1KB')
35
+ expect(formatFileSize(1048575)).toBe('1024KB')
36
+ expect(formatFileSize(1048577)).toBe('1MB')
37
+ })
38
+ })
@@ -0,0 +1,13 @@
1
+ import { FILE_SIZE_UNITS } from './constants'
2
+
3
+ export function formatFileSize(size: number): string {
4
+ if (size < FILE_SIZE_UNITS.KB) {
5
+ return `${size}B`
6
+ }
7
+
8
+ if (size < FILE_SIZE_UNITS.MB) {
9
+ return `${Math.round((size / FILE_SIZE_UNITS.KB) * 10) / 10}KB`
10
+ }
11
+
12
+ return `${Math.round((size / FILE_SIZE_UNITS.MB) * 10) / 10}MB`
13
+ }
@@ -0,0 +1,19 @@
1
+ import type { ChatFileUploaderProps } from './types'
2
+
3
+ export class ChatFileUploaderBuilder {
4
+ private props: ChatFileUploaderProps = {}
5
+
6
+ withDisabled(disabled: boolean): ChatFileUploaderBuilder {
7
+ this.props.disabled = disabled
8
+ return this
9
+ }
10
+
11
+ withLoading(loading: boolean): ChatFileUploaderBuilder {
12
+ this.props.loading = loading
13
+ return this
14
+ }
15
+
16
+ build(): ChatFileUploaderProps {
17
+ return this.props
18
+ }
19
+ }
@@ -0,0 +1,58 @@
1
+ import { render, screen } from '@/src/config/tests'
2
+
3
+ import ChatFileUploader from './chat-file-uploader'
4
+ import { ChatFileUploaderBuilder } from './chat-file-uploader.builder'
5
+
6
+ vi.mock('@/src/lib/hooks/use-chat-file-upload', () => ({
7
+ useChatFileUpload: vi.fn(() => ({
8
+ handleSelectFile: vi.fn(),
9
+ selectedFile: null
10
+ }))
11
+ }))
12
+
13
+ const defaultProps = new ChatFileUploaderBuilder().build()
14
+ const renderComponent = (props = defaultProps) => render(<ChatFileUploader {...props} />)
15
+
16
+ describe('ChatFileUploader', () => {
17
+ it('should be disabled when disabled prop is true', () => {
18
+ const props = new ChatFileUploaderBuilder().withDisabled(true).build()
19
+ renderComponent(props)
20
+
21
+ const button = screen.getByRole('button', {
22
+ name: /general.buttons.open_options/i
23
+ })
24
+ expect(button).toBeDisabled()
25
+ })
26
+
27
+ it('should be disabled when loading prop is true', () => {
28
+ const props = new ChatFileUploaderBuilder().withLoading(true).build()
29
+ renderComponent(props)
30
+
31
+ const button = screen.getByRole('button', {
32
+ name: /general.buttons.open_options/i
33
+ })
34
+ expect(button).toBeDisabled()
35
+ })
36
+
37
+ it('should be enabled when both disabled and loading are false', () => {
38
+ const props = new ChatFileUploaderBuilder().withDisabled(false).withLoading(false).build()
39
+ renderComponent(props)
40
+
41
+ const button = screen.getByRole('button', {
42
+ name: /general.buttons.open_options/i
43
+ })
44
+ expect(button).toBeEnabled()
45
+ })
46
+
47
+ it('should trigger file input when clicking file option', async () => {
48
+ const { user } = renderComponent()
49
+
50
+ const button = screen.getByRole('button', {
51
+ name: /general.buttons.open_options/i
52
+ })
53
+ await user.click(button)
54
+
55
+ expect(screen.getByText('general.buttons.file')).toBeInTheDocument()
56
+ expect(screen.getByText('general.buttons.image')).toBeInTheDocument()
57
+ })
58
+ })