app-tutor-ai-consumer 1.2.0 → 1.4.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.
- package/CHANGELOG.md +23 -0
- package/config/vitest/__mocks__/sparkie.tsx +12 -0
- package/config/vitest/setupTests.ts +6 -1
- package/config/vitest/vitest.config.mts +1 -0
- package/environments/.env.test +2 -2
- package/package.json +12 -2
- package/public/assets/images/default-image.png +0 -0
- package/public/assets/svg/tutor-onboarding.svg +128 -0
- package/src/@types/declarations.d.ts +16 -6
- package/src/config/dayjs/index.ts +2 -0
- package/src/config/dayjs/init.ts +28 -0
- package/src/config/dayjs/utils/format-fulldate.ts +7 -0
- package/src/config/dayjs/utils/format-time.ts +20 -0
- package/src/config/dayjs/utils/index.ts +2 -0
- package/src/config/optimizely/optimizely-provider.tsx +3 -3
- package/src/config/optimizely/optimizely.ts +1 -1
- package/src/config/styles/global.css +20 -1
- package/src/config/styles/utilities/bg-utilities.module.css +11 -0
- package/src/config/styles/utilities/text-utilities.module.css +6 -0
- package/src/config/tanstack/query-provider.tsx +1 -1
- package/src/config/tests/handlers.ts +9 -0
- package/src/index.tsx +4 -0
- package/src/lib/components/button/button.tsx +86 -0
- package/src/lib/components/button/index.ts +1 -0
- package/src/lib/components/index.ts +2 -0
- package/src/lib/components/markdownrenderer/__tests__/markdown.stub.ts +334 -0
- package/src/lib/components/markdownrenderer/components/index.ts +1 -0
- package/src/lib/components/markdownrenderer/components/md-code-block/index.ts +1 -0
- package/src/lib/components/markdownrenderer/components/md-code-block/md-code-block.tsx +71 -0
- package/src/lib/components/markdownrenderer/index.ts +2 -0
- package/src/lib/components/markdownrenderer/markdownrenderer.tsx +115 -0
- package/src/lib/hooks/index.ts +1 -0
- package/src/lib/hooks/use-ref-event-listener/index.ts +2 -0
- package/src/lib/hooks/use-ref-event-listener/use-ref-event-listener.tsx +32 -0
- package/src/lib/utils/constants.ts +1 -1
- package/src/lib/utils/copy-text-to-clipboard.tsx +13 -0
- package/src/lib/utils/extract-text-from-react-nodes.ts +23 -0
- package/src/lib/utils/index.ts +3 -0
- package/src/lib/utils/urls.ts +20 -0
- package/src/main/main.spec.tsx +17 -7
- package/src/main/main.tsx +2 -13
- package/src/modules/messages/__tests__/imessage-with-sender-data.builder.ts +113 -0
- package/src/modules/messages/__tests__/imessage-with-sender-data.mock.ts +15 -0
- package/src/modules/messages/components/chat-input/chat-input.atom.ts +12 -0
- package/src/modules/{create-message → messages}/components/chat-input/chat-input.tsx +6 -2
- package/src/modules/{create-message → messages}/components/chat-input/index.ts +1 -0
- package/src/modules/{create-message → messages}/components/chat-input/types.ts +1 -0
- package/src/modules/messages/components/index.ts +4 -0
- package/src/modules/messages/components/message-img/index.ts +1 -0
- package/src/modules/messages/components/message-img/message-img.tsx +47 -0
- package/src/modules/messages/components/message-item/index.ts +2 -0
- package/src/modules/messages/components/message-item/message-item.spec.tsx +26 -0
- package/src/modules/messages/components/message-item/message-item.tsx +15 -0
- package/src/modules/messages/components/messages-list/index.ts +2 -0
- package/src/modules/messages/components/messages-list/messages-list.tsx +53 -0
- package/src/modules/messages/constants.ts +1 -0
- package/src/modules/messages/hooks/index.ts +1 -0
- package/src/modules/messages/hooks/use-fetch-messages/index.ts +2 -0
- package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.spec.tsx +46 -0
- package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.tsx +103 -0
- package/src/modules/messages/index.ts +3 -0
- package/src/modules/messages/service.ts +86 -0
- package/src/modules/messages/types.ts +78 -0
- package/src/modules/messages/utils/index.ts +1 -0
- package/src/modules/messages/utils/messages-parser/index.ts +1 -0
- package/src/modules/messages/utils/messages-parser/utils.ts +28 -0
- package/src/modules/profile/__tests__/profile-api-props.builder.ts +74 -0
- package/src/modules/profile/__tests__/profile-props.builder.ts +42 -0
- package/src/modules/profile/constants.ts +3 -0
- package/src/modules/profile/hooks/index.ts +1 -0
- package/src/modules/profile/hooks/use-get-profile/index.ts +1 -0
- package/src/modules/profile/hooks/use-get-profile/use-get-profile.spec.tsx +20 -0
- package/src/modules/profile/hooks/use-get-profile/use-get-profile.tsx +14 -0
- package/src/modules/profile/index.ts +4 -0
- package/src/modules/profile/service.tsx +19 -0
- package/src/modules/profile/types.ts +17 -0
- package/src/modules/sparkie/constants.ts +21 -0
- package/src/modules/sparkie/index.ts +3 -0
- package/src/modules/sparkie/service.ts +94 -0
- package/src/modules/sparkie/types.ts +47 -0
- package/src/modules/sparkie/utils/validate-firebase-config.spec.ts +17 -0
- package/src/modules/sparkie/utils/validate-firebase-config.ts +12 -0
- package/src/modules/widget/__tests__/widget-settings-props.builder.ts +121 -0
- package/src/modules/widget/components/chat-page/chat-page.tsx +20 -0
- package/src/modules/widget/components/chat-page/index.ts +2 -0
- package/src/modules/widget/components/constants.tsx +9 -0
- package/src/modules/widget/components/container/container.tsx +32 -0
- package/src/modules/widget/components/container/index.ts +2 -0
- package/src/modules/widget/components/container/types.ts +3 -0
- package/src/modules/widget/components/greetings-card/greetings-card.tsx +1 -1
- package/src/modules/widget/components/greetings-card/styles.module.css +1 -3
- package/src/modules/widget/components/index.ts +3 -0
- package/src/modules/widget/components/onboarding-page/index.ts +1 -0
- package/src/modules/widget/components/onboarding-page/onboarding-page.tsx +40 -0
- package/src/modules/widget/components/onboarding-page/styles.module.css +7 -0
- package/src/modules/widget/components/starter-page/index.ts +1 -0
- package/src/modules/widget/components/starter-page/starter-page.tsx +41 -0
- package/src/modules/widget/hooks/index.ts +1 -0
- package/src/modules/widget/hooks/use-init-sparkie/index.ts +1 -0
- package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +18 -0
- package/src/modules/widget/store/index.ts +1 -0
- package/src/modules/widget/store/widget-settings.atom.ts +3 -1
- package/src/modules/widget/store/widget-tabs.atom.ts +53 -0
- package/tailwind.config.js +95 -1
- package/config/vitest/index.ts +0 -1
- package/src/config/styles/shared-styles.module.css +0 -16
- package/src/main/styles.module.css +0 -15
- package/src/modules/create-message/components/index.ts +0 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { memo, useState } from 'react'
|
|
2
|
+
import { Highlight, themes } from 'prism-react-renderer'
|
|
3
|
+
import type { PropsWithChildren } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
|
|
6
|
+
import { copyTextToClipboard, extractTextFromReactNodes } from '@/src/lib/utils'
|
|
7
|
+
|
|
8
|
+
export type MdCodeBlockProps = PropsWithChildren<{
|
|
9
|
+
inline?: boolean
|
|
10
|
+
className?: string
|
|
11
|
+
}>
|
|
12
|
+
|
|
13
|
+
const MdCodeBlock = memo(({ inline, className, children, ...props }: MdCodeBlockProps) => {
|
|
14
|
+
const { t } = useTranslation()
|
|
15
|
+
const [copied, setCopied] = useState(false)
|
|
16
|
+
const codeContent = extractTextFromReactNodes(children)
|
|
17
|
+
const match = /language-(\w+)/.exec(className || '')
|
|
18
|
+
const language = match ? match[1] : 'text'
|
|
19
|
+
|
|
20
|
+
const handleCopy = () => {
|
|
21
|
+
setCopied(true)
|
|
22
|
+
void copyTextToClipboard(codeContent)
|
|
23
|
+
.then(
|
|
24
|
+
(result) =>
|
|
25
|
+
new Promise((resolve, reject) => {
|
|
26
|
+
if (!result) return reject(new Error('copy failed'))
|
|
27
|
+
setTimeout(resolve, 2000)
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
.finally(() => setCopied(false))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (inline) {
|
|
34
|
+
return (
|
|
35
|
+
<code className='rounded bg-neutral-800 px-2 py-1 font-mono text-sm text-pink-600' {...props}>
|
|
36
|
+
{children}
|
|
37
|
+
</code>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className='group relative'>
|
|
43
|
+
<div className='flex items-center justify-between rounded-t-lg bg-neutral-800 px-4 py-2 text-sm text-neutral-300'>
|
|
44
|
+
<span className='font-mono text-xs uppercase tracking-wide'>{language}</span>
|
|
45
|
+
<button
|
|
46
|
+
onClick={handleCopy}
|
|
47
|
+
className='rounded bg-neutral-700 px-2 py-1 text-xs opacity-0 transition-opacity duration-200 hover:bg-neutral-600 hover:text-neutral-0 group-hover:opacity-100'>
|
|
48
|
+
{t(copied ? `general.buttons.copied` : 'general.buttons.copy')}
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<Highlight theme={themes.vsDark} code={codeContent} language={language}>
|
|
53
|
+
{({ className: highlightClassName, style, tokens, getLineProps, getTokenProps }) => (
|
|
54
|
+
<pre className={`${highlightClassName} overflow-x-auto rounded-b-lg p-4`} style={style}>
|
|
55
|
+
{tokens.map((line, i) => (
|
|
56
|
+
<div key={i} {...getLineProps({ line })}>
|
|
57
|
+
{line.map((token, key) => (
|
|
58
|
+
<span key={key} {...getTokenProps({ token })} />
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</pre>
|
|
63
|
+
)}
|
|
64
|
+
</Highlight>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
MdCodeBlock.displayName = 'MdCodeBlock'
|
|
70
|
+
|
|
71
|
+
export default MdCodeBlock
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
import Markdown, { type Components } from 'react-markdown'
|
|
3
|
+
import rehypeRaw from 'rehype-raw'
|
|
4
|
+
import rehypeSanitize from 'rehype-sanitize'
|
|
5
|
+
import remarkBreaks from 'remark-breaks'
|
|
6
|
+
import remarkGfm from 'remark-gfm'
|
|
7
|
+
|
|
8
|
+
import { URLutils } from '../../utils'
|
|
9
|
+
|
|
10
|
+
import MdCodeBlock from './components/md-code-block'
|
|
11
|
+
|
|
12
|
+
const mdComponents: Partial<Components> = {
|
|
13
|
+
h1({ children, ...props }) {
|
|
14
|
+
return (
|
|
15
|
+
<h1 className='mb-4 border-b pb-2 text-2xl font-bold' {...props}>
|
|
16
|
+
{children}
|
|
17
|
+
</h1>
|
|
18
|
+
)
|
|
19
|
+
},
|
|
20
|
+
h2({ children, ...props }) {
|
|
21
|
+
return (
|
|
22
|
+
<h2 className='mb-3 mt-6 text-xl font-semibold' {...props}>
|
|
23
|
+
{children}
|
|
24
|
+
</h2>
|
|
25
|
+
)
|
|
26
|
+
},
|
|
27
|
+
code(props) {
|
|
28
|
+
return <MdCodeBlock {...props} />
|
|
29
|
+
},
|
|
30
|
+
pre({ children }) {
|
|
31
|
+
return (
|
|
32
|
+
<span className='my-2 inline-block w-full overflow-hidden rounded-lg border'>{children}</span>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
a({ href, children, ...props }) {
|
|
36
|
+
const url = URLutils.getURLwithProtocol(href)
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<a
|
|
40
|
+
href={url}
|
|
41
|
+
target={url?.startsWith('http') ? '_blank' : '_self'}
|
|
42
|
+
rel={url?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
43
|
+
className='text-blue-600 underline hover:text-blue-800'
|
|
44
|
+
{...props}>
|
|
45
|
+
{children}
|
|
46
|
+
</a>
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
table({ children, ...props }) {
|
|
50
|
+
return (
|
|
51
|
+
<div className='overflow-x-auto'>
|
|
52
|
+
<table className='min-w-full border-collapse border border-neutral-300' {...props}>
|
|
53
|
+
{children}
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
},
|
|
58
|
+
th({ children, ...props }) {
|
|
59
|
+
return (
|
|
60
|
+
<th className='border border-neutral-300 px-4 py-2 text-left font-semibold' {...props}>
|
|
61
|
+
{children}
|
|
62
|
+
</th>
|
|
63
|
+
)
|
|
64
|
+
},
|
|
65
|
+
td({ children, ...props }) {
|
|
66
|
+
return (
|
|
67
|
+
<td className='border border-neutral-300 px-4 py-2' {...props}>
|
|
68
|
+
{children}
|
|
69
|
+
</td>
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
blockquote({ children, ...props }) {
|
|
73
|
+
return (
|
|
74
|
+
<blockquote
|
|
75
|
+
className='my-2 border-l-4 border-blue-500 pl-4 italic text-neutral-100'
|
|
76
|
+
{...props}>
|
|
77
|
+
{children}
|
|
78
|
+
</blockquote>
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
p({ children }) {
|
|
82
|
+
return <span className='my-2 inline-block'>{children}</span>
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type MarkdownRendererProps = {
|
|
87
|
+
content?: string
|
|
88
|
+
className?: string
|
|
89
|
+
allowDangerousHtml?: boolean
|
|
90
|
+
enableGfm?: boolean
|
|
91
|
+
imgComponent?: Components['img']
|
|
92
|
+
}
|
|
93
|
+
function MarkdownRenderer({
|
|
94
|
+
content,
|
|
95
|
+
allowDangerousHtml,
|
|
96
|
+
className,
|
|
97
|
+
enableGfm = true,
|
|
98
|
+
imgComponent
|
|
99
|
+
}: MarkdownRendererProps) {
|
|
100
|
+
const remarkPlugins = [...(enableGfm ? [remarkGfm] : []), remarkBreaks]
|
|
101
|
+
const rehypePlugins = [rehypeSanitize, ...(allowDangerousHtml ? [rehypeRaw] : [])]
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={clsx('max-w-none', className)}>
|
|
105
|
+
<Markdown
|
|
106
|
+
remarkPlugins={remarkPlugins}
|
|
107
|
+
rehypePlugins={rehypePlugins}
|
|
108
|
+
components={!imgComponent ? mdComponents : { ...mdComponents, img: imgComponent }}>
|
|
109
|
+
{content}
|
|
110
|
+
</Markdown>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default MarkdownRenderer
|
package/src/lib/hooks/index.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
export type UseRefEventListenerConfig<T extends HTMLElement> = {
|
|
4
|
+
config: {
|
|
5
|
+
ref: React.RefObject<T | null>
|
|
6
|
+
eventTypes: string[]
|
|
7
|
+
handler: (event: Event) => void
|
|
8
|
+
options?: AddEventListenerOptions
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function useRefEventListener<T extends HTMLElement>({
|
|
13
|
+
config: { eventTypes, handler, ref, options }
|
|
14
|
+
}: UseRefEventListenerConfig<T>) {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const element = ref.current
|
|
17
|
+
|
|
18
|
+
if (!element) return
|
|
19
|
+
|
|
20
|
+
eventTypes.forEach((ev) => {
|
|
21
|
+
element.addEventListener(ev, handler, options)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
eventTypes.forEach((ev) => {
|
|
26
|
+
element.removeEventListener(ev, handler, options)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}, [ref, eventTypes, handler, options])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default useRefEventListener
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const devMode = process.env.NODE_ENV === 'development'
|
|
2
2
|
export const productionMode = process.env.NODE_ENV === 'production'
|
|
3
3
|
export const RELEASE_FULL_NAME = `${process.env.RELEASE_FULL_NAME}_${process.env.NODE_ENV}`
|
|
4
|
-
export const DEFAULT_STALE_TIME = 1000 * 60 *
|
|
4
|
+
export const DEFAULT_STALE_TIME = 1000 * 60 * 3 // 3 minutes
|
|
5
5
|
export const APP_VERSION = process.env.PROJECT_VERSION ?? ''
|
|
6
6
|
export const APP_SYSTEM = 'Web'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function copyTextToClipboard(textToCopy: string) {
|
|
2
|
+
try {
|
|
3
|
+
if (navigator?.clipboard?.writeText) {
|
|
4
|
+
await navigator.clipboard.writeText(textToCopy)
|
|
5
|
+
return true
|
|
6
|
+
}
|
|
7
|
+
throw Error('Clipboard API not available or writeText method not supported.')
|
|
8
|
+
} catch (err) {
|
|
9
|
+
console.error('Failed to copy text:', err)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isValidElement } from 'react'
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
export type ElementWithProps = ReactElement & { props: { children: ReactNode } }
|
|
5
|
+
|
|
6
|
+
export const hasChildren = (element: ReactElement): element is ElementWithProps => {
|
|
7
|
+
const children = element as ElementWithProps
|
|
8
|
+
|
|
9
|
+
return children.props && 'children' in children.props
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const extractTextFromReactNodes = (children: ReactNode): string => {
|
|
13
|
+
if (typeof children === 'string' || typeof children === 'number') {
|
|
14
|
+
return String(children)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (Array.isArray(children)) return children.map(extractTextFromReactNodes).join('')
|
|
18
|
+
|
|
19
|
+
if (isValidElement(children) && hasChildren(children))
|
|
20
|
+
return extractTextFromReactNodes(children.props.children)
|
|
21
|
+
|
|
22
|
+
return ''
|
|
23
|
+
}
|
package/src/lib/utils/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export * from './constants'
|
|
2
|
+
export * from './copy-text-to-clipboard'
|
|
3
|
+
export * from './extract-text-from-react-nodes'
|
|
2
4
|
export { default as HttpCodes } from './http-codes'
|
|
3
5
|
export * from './languages'
|
|
4
6
|
export * from './message-types'
|
|
7
|
+
export { default as URLutils } from './urls'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Match } from 'linkify-it'
|
|
2
|
+
import linkifyit from 'linkify-it'
|
|
3
|
+
|
|
4
|
+
class URLutils {
|
|
5
|
+
private linkify: linkifyit
|
|
6
|
+
|
|
7
|
+
constructor() {
|
|
8
|
+
this.linkify = new linkifyit()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
getURLwithProtocol = (url?: string) => {
|
|
12
|
+
if (!url) return url
|
|
13
|
+
|
|
14
|
+
const [result = {} as Match] = this.linkify?.match(url) ?? []
|
|
15
|
+
|
|
16
|
+
return result?.url ?? `http://${url}`
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default new URLutils()
|
package/src/main/main.spec.tsx
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import { render, screen, waitFor } from '@/config/tests'
|
|
2
|
-
import
|
|
1
|
+
import { chance, render, screen, waitFor } from '@/config/tests'
|
|
2
|
+
import WidgetSettingPropsBuilder from '../modules/widget/__tests__/widget-settings-props.builder'
|
|
3
3
|
import { Main } from '.'
|
|
4
4
|
|
|
5
5
|
describe('Main', () => {
|
|
6
|
-
const
|
|
6
|
+
const defaultProps = new WidgetSettingPropsBuilder()
|
|
7
|
+
const renderComponent = (props = { settings: defaultProps }) => render(<Main {...props} />)
|
|
8
|
+
|
|
9
|
+
it('should render empty element when settings.tutorName is not defined', async () => {
|
|
10
|
+
const { container } = renderComponent()
|
|
11
|
+
|
|
12
|
+
await waitFor(() => {
|
|
13
|
+
expect(container).toBeEmptyDOMElement()
|
|
14
|
+
})
|
|
15
|
+
})
|
|
7
16
|
|
|
8
17
|
it('should render without errors', async () => {
|
|
9
|
-
|
|
18
|
+
const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
|
|
19
|
+
renderComponent({ settings: props })
|
|
10
20
|
|
|
11
|
-
await waitFor(() =>
|
|
12
|
-
expect(screen.getByText(/
|
|
13
|
-
)
|
|
21
|
+
await waitFor(() => {
|
|
22
|
+
expect(screen.getByText(/onboarding.description/i)).toBeInTheDocument()
|
|
23
|
+
})
|
|
14
24
|
})
|
|
15
25
|
})
|
package/src/main/main.tsx
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import '@/config/styles/index.css'
|
|
2
2
|
|
|
3
|
-
import clsx from 'clsx'
|
|
4
|
-
|
|
5
3
|
import { ErrorBoundary, GenericError } from '@/src/lib/components/errors'
|
|
6
4
|
import { useDefaultId } from '@/src/lib/hooks'
|
|
7
5
|
import { useAppLang } from '../config/i18n'
|
|
8
|
-
import { ChatInput } from '../modules/create-message/components'
|
|
9
6
|
import { GlobalProviders } from '../modules/global-providers'
|
|
10
|
-
import {
|
|
7
|
+
import { WidgetContainer } from '../modules/widget'
|
|
11
8
|
import type { WidgetSettingProps } from '../types'
|
|
12
9
|
|
|
13
|
-
import styles from './styles.module.css'
|
|
14
|
-
|
|
15
10
|
function Main({ settings }: { settings: WidgetSettingProps }) {
|
|
16
11
|
useDefaultId()
|
|
17
12
|
useAppLang(settings.locale)
|
|
@@ -19,13 +14,7 @@ function Main({ settings }: { settings: WidgetSettingProps }) {
|
|
|
19
14
|
return (
|
|
20
15
|
<ErrorBoundary fallback={<GenericError />}>
|
|
21
16
|
<GlobalProviders settings={settings}>
|
|
22
|
-
<
|
|
23
|
-
className={clsx('flex min-h-svh flex-col items-center justify-center p-5', styles.main)}>
|
|
24
|
-
<div className='flex flex-1 flex-col justify-center gap-6 lg:max-w-sm'>
|
|
25
|
-
<GreetingsCard author={settings.author ?? ''} tutorName={settings.tutorName ?? ''} />
|
|
26
|
-
<ChatInput name='new-chat-msg-input' />
|
|
27
|
-
</div>
|
|
28
|
-
</div>
|
|
17
|
+
<WidgetContainer />
|
|
29
18
|
</GlobalProviders>
|
|
30
19
|
</ErrorBoundary>
|
|
31
20
|
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { MessageContent } from '@hotmart/sparkie/dist/MessageService'
|
|
2
|
+
|
|
3
|
+
import { chance } from '@/src/config/tests'
|
|
4
|
+
import type { IMessageWithSenderData } from '../types'
|
|
5
|
+
|
|
6
|
+
class IMessageWithSenderDataBuilder implements IMessageWithSenderData {
|
|
7
|
+
id: string
|
|
8
|
+
conversationId: string
|
|
9
|
+
threadId: string
|
|
10
|
+
contactId: string
|
|
11
|
+
type: string
|
|
12
|
+
channel: string
|
|
13
|
+
content: MessageContent
|
|
14
|
+
metadata: {
|
|
15
|
+
author: 'ai' | 'user'
|
|
16
|
+
sessionId: string
|
|
17
|
+
externalId: string
|
|
18
|
+
correlationId: string
|
|
19
|
+
}
|
|
20
|
+
sentAt: number
|
|
21
|
+
updatedAt: number
|
|
22
|
+
contact: { id: string; name?: string; picture?: string; userId?: number }
|
|
23
|
+
parentId?: string
|
|
24
|
+
deletedAt?: number
|
|
25
|
+
sending?: boolean
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
this.id = chance.guid()
|
|
29
|
+
this.conversationId = chance.guid()
|
|
30
|
+
this.threadId = chance.guid()
|
|
31
|
+
this.contactId = chance.guid()
|
|
32
|
+
this.type = chance.animal()
|
|
33
|
+
this.channel = chance.name()
|
|
34
|
+
this.contact = { id: chance.guid() }
|
|
35
|
+
this.content = { type: chance.cc_type(), text: chance.sentence() }
|
|
36
|
+
this.metadata = {
|
|
37
|
+
author: 'ai',
|
|
38
|
+
sessionId: chance.guid(),
|
|
39
|
+
externalId: chance.guid(),
|
|
40
|
+
correlationId: chance.guid()
|
|
41
|
+
}
|
|
42
|
+
this.sentAt = chance.date().getTime()
|
|
43
|
+
this.updatedAt = chance.date().getTime()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
withId(id: typeof this.id) {
|
|
47
|
+
this.id = id
|
|
48
|
+
|
|
49
|
+
return this
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
withConversationId(conversationId: typeof this.conversationId) {
|
|
53
|
+
this.conversationId = conversationId
|
|
54
|
+
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
withThreadId(threadId: typeof this.threadId) {
|
|
59
|
+
this.threadId = threadId
|
|
60
|
+
|
|
61
|
+
return this
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
withContactId(contactId: typeof this.contactId) {
|
|
65
|
+
this.contactId = contactId
|
|
66
|
+
|
|
67
|
+
return this
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
withType(type: typeof this.type) {
|
|
71
|
+
this.type = type
|
|
72
|
+
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
withChannel(channel: typeof this.channel) {
|
|
77
|
+
this.channel = channel
|
|
78
|
+
|
|
79
|
+
return this
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
withContact(contact: typeof this.contact) {
|
|
83
|
+
this.contact = contact
|
|
84
|
+
|
|
85
|
+
return this
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
withContent(content: typeof this.content) {
|
|
89
|
+
this.content = content
|
|
90
|
+
|
|
91
|
+
return this
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
withMetadata(metadata: typeof this.metadata) {
|
|
95
|
+
this.metadata = metadata
|
|
96
|
+
|
|
97
|
+
return this
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
withSentAt(sentAt: typeof this.sentAt) {
|
|
101
|
+
this.sentAt = sentAt
|
|
102
|
+
|
|
103
|
+
return this
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
withUpdatedAt(updatedAt: typeof this.updatedAt) {
|
|
107
|
+
this.updatedAt = updatedAt
|
|
108
|
+
|
|
109
|
+
return this
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default IMessageWithSenderDataBuilder
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { MockGenerator } from '@/src/config/tests'
|
|
2
|
+
import type { IMessageWithSenderData } from '../types'
|
|
3
|
+
|
|
4
|
+
import IMessageWithSenderDataBuilder from './imessage-with-sender-data.builder'
|
|
5
|
+
|
|
6
|
+
class IMessageWithSenderDataMock extends MockGenerator<Partial<IMessageWithSenderData>> {
|
|
7
|
+
getOne(properties?: Partial<IMessageWithSenderData>): IMessageWithSenderData {
|
|
8
|
+
return {
|
|
9
|
+
...new IMessageWithSenderDataBuilder(),
|
|
10
|
+
...properties
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default IMessageWithSenderDataMock
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { atom, useAtom } from 'jotai'
|
|
2
|
+
|
|
3
|
+
const chatInputValueAtom = atom<string>('')
|
|
4
|
+
|
|
5
|
+
export const chatInputAtom = atom(
|
|
6
|
+
(get) => get(chatInputValueAtom),
|
|
7
|
+
(_, set, config: string) => {
|
|
8
|
+
set(chatInputValueAtom, config)
|
|
9
|
+
}
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
export const useChatInputValueAtom = () => useAtom(chatInputAtom)
|
|
@@ -3,10 +3,13 @@ import { useTranslation } from 'react-i18next'
|
|
|
3
3
|
|
|
4
4
|
import { Icon } from '@/src/lib/components'
|
|
5
5
|
|
|
6
|
+
import { useChatInputValueAtom } from './chat-input.atom'
|
|
6
7
|
import type { ChatInputProps } from './types'
|
|
7
8
|
|
|
8
|
-
const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name }, ref) {
|
|
9
|
+
const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name, onSend }, ref) {
|
|
9
10
|
const { t } = useTranslation()
|
|
11
|
+
const [value] = useChatInputValueAtom()
|
|
12
|
+
|
|
10
13
|
return (
|
|
11
14
|
<div className='flex items-center rounded-full border-neutral-800 bg-neutral-800 px-4 py-2'>
|
|
12
15
|
<input
|
|
@@ -16,8 +19,9 @@ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name
|
|
|
16
19
|
type='text'
|
|
17
20
|
className='h-6 w-full border-none bg-transparent text-neutral-400 outline-0 placeholder:text-neutral-400'
|
|
18
21
|
placeholder={t('send_message.field.placeholder')}
|
|
22
|
+
defaultValue={value}
|
|
19
23
|
/>
|
|
20
|
-
<button>
|
|
24
|
+
<button onClick={onSend}>
|
|
21
25
|
<Icon name='send' className='h-4 w-4 text-neutral-50' />
|
|
22
26
|
</button>
|
|
23
27
|
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as MessageImg } from './message-img'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import clsx from 'clsx'
|
|
3
|
+
|
|
4
|
+
import { Spinner } from '@/src/lib/components'
|
|
5
|
+
import type { ParsedMessage } from '../../types'
|
|
6
|
+
|
|
7
|
+
import DefaultImage from '@/public/assets/images/default-image.png'
|
|
8
|
+
|
|
9
|
+
const BASE_WIDTH = 200
|
|
10
|
+
|
|
11
|
+
export type MessageImgProps = {
|
|
12
|
+
message: ParsedMessage
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function MessageImg({ message: { dimensions, thumbnails, url, name } }: MessageImgProps) {
|
|
16
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
17
|
+
|
|
18
|
+
let height = BASE_WIDTH
|
|
19
|
+
|
|
20
|
+
if (!url) return null
|
|
21
|
+
|
|
22
|
+
const thumbURL = thumbnails?.md || thumbnails?.sm || thumbnails?.lg || url
|
|
23
|
+
|
|
24
|
+
if (dimensions?.width) {
|
|
25
|
+
height = (height / dimensions.width) * BASE_WIDTH
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<a href={url} target='_blank' rel='noopener noreferrer'>
|
|
30
|
+
{isLoading && <Spinner className={`h-12 w-12`} />}
|
|
31
|
+
<img
|
|
32
|
+
width={BASE_WIDTH}
|
|
33
|
+
height={height}
|
|
34
|
+
src={thumbURL}
|
|
35
|
+
alt={name}
|
|
36
|
+
className={clsx({ hidden: isLoading })}
|
|
37
|
+
onLoad={() => setIsLoading(false)}
|
|
38
|
+
onError={({ currentTarget }) => {
|
|
39
|
+
currentTarget.src = DefaultImage
|
|
40
|
+
setIsLoading(false)
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
</a>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default MessageImg
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { render, screen } from '@/src/config/tests'
|
|
2
|
+
import { TEST_MARKDOWN_STUB } from '@/src/lib/components/markdownrenderer/__tests__/markdown.stub'
|
|
3
|
+
import type { ParsedMessage } from '../../types'
|
|
4
|
+
|
|
5
|
+
import MessageItem from './message-item'
|
|
6
|
+
|
|
7
|
+
describe('MessageItem', () => {
|
|
8
|
+
const message = { text: TEST_MARKDOWN_STUB } as ParsedMessage
|
|
9
|
+
|
|
10
|
+
const renderComponent = (props = { message }) => render(<MessageItem {...props} />)
|
|
11
|
+
|
|
12
|
+
it('should render markdown as html', () => {
|
|
13
|
+
renderComponent()
|
|
14
|
+
|
|
15
|
+
const reactDocLink = screen.getByRole('link', { name: /External Link to React Documentation/i })
|
|
16
|
+
|
|
17
|
+
expect(reactDocLink).toBeInTheDocument()
|
|
18
|
+
expect(reactDocLink).toHaveAttribute('href', 'https://reactjs.org/docs/getting-started.html')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should render the custom image component', () => {
|
|
22
|
+
renderComponent()
|
|
23
|
+
|
|
24
|
+
expect(screen.getAllByRole('img')).toHaveLength(3)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Components } from 'react-markdown'
|
|
2
|
+
|
|
3
|
+
import { MarkdownRenderer } from '@/src/lib/components'
|
|
4
|
+
import type { ParsedMessage } from '../../types'
|
|
5
|
+
import { MessageImg } from '../message-img'
|
|
6
|
+
|
|
7
|
+
const imgComponent: Components['img'] = ({ src }) => {
|
|
8
|
+
return <MessageImg message={{ thumbnails: {}, url: src, dimensions: {} } as ParsedMessage} />
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function MessageItem({ message }: { message: ParsedMessage }) {
|
|
12
|
+
return <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default MessageItem
|