├── .changeset
└── config.json
├── .env.example
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── README.md
├── examples
├── basic-embed
│ └── index.html
├── installable-embed
│ ├── README.md
│ ├── index.html
│ ├── package-lock.json
│ └── package.json
├── react-18
│ ├── .gitignore
│ ├── README.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ └── vite.svg
│ ├── src
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── assets
│ │ │ └── react.svg
│ │ ├── index.css
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── react-19
│ ├── .gitignore
│ ├── README.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ └── vite.svg
│ ├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── package.json
├── packages
├── core
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── __tests__
│ │ │ ├── api-caller.mock.ts
│ │ │ ├── context
│ │ │ │ └── contact
│ │ │ │ │ ├── auth
│ │ │ │ │ ├── auto-create-unverified-anonymous.spec.ts
│ │ │ │ │ ├── auto-create-unverified-with-user-data-provided-by-org.spec.ts
│ │ │ │ │ ├── manually-create-unverified-with-user-data-provided-by-user.spec.ts
│ │ │ │ │ └── with-secure-token.spec.ts
│ │ │ │ │ └── should-collect-data.spec.ts
│ │ │ ├── test-utils.ts
│ │ │ └── utils
│ │ │ │ ├── Poller.spec.ts
│ │ │ │ └── PrimitiveState.spec.ts
│ │ ├── api
│ │ │ ├── api-caller.ts
│ │ │ ├── client.ts
│ │ │ └── schema.ts
│ │ ├── context
│ │ │ ├── active-session-polling.ctx.ts
│ │ │ ├── contact.ctx.ts
│ │ │ ├── csat.ctx.ts
│ │ │ ├── message.ctx.ts
│ │ │ ├── router.ctx.ts
│ │ │ ├── session.ctx.ts
│ │ │ ├── storage.ctx.ts
│ │ │ └── widget.ctx.ts
│ │ ├── index.ts
│ │ ├── translation
│ │ │ ├── ar.ts
│ │ │ ├── da.ts
│ │ │ ├── de.ts
│ │ │ ├── en.ts
│ │ │ ├── es.ts
│ │ │ ├── fi.ts
│ │ │ ├── fr.ts
│ │ │ ├── index.ts
│ │ │ ├── it.ts
│ │ │ ├── nl.ts
│ │ │ ├── no.ts
│ │ │ ├── pl.ts
│ │ │ ├── pt.ts
│ │ │ ├── ro.ts
│ │ │ ├── sv.ts
│ │ │ └── tr.ts
│ │ ├── types
│ │ │ ├── agent-or-bot.ts
│ │ │ ├── component-name.ts
│ │ │ ├── dtos.ts
│ │ │ ├── external-storage.ts
│ │ │ ├── helpers.ts
│ │ │ ├── icons.ts
│ │ │ ├── json-value.ts
│ │ │ ├── messages.ts
│ │ │ └── widget-config.ts
│ │ ├── utils
│ │ │ ├── Poller.ts
│ │ │ ├── PrimitiveState.ts
│ │ │ ├── is-exhaustive.ts
│ │ │ ├── run-catching.ts
│ │ │ └── uuid.ts
│ │ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── vitest.config.ts
├── embed
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── src
│ │ └── index.tsx
│ ├── tsconfig.json
│ ├── vite-env.d.ts
│ ├── vite.config.ts
│ └── vitest.config.ts
├── eslint-config
│ ├── CHANGELOG.md
│ ├── base.js
│ └── package.json
├── react-headless
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── ComponentRegistry.ts
│ │ ├── WidgetProvider.tsx
│ │ ├── hooks
│ │ │ ├── useConfig.ts
│ │ │ ├── useContact.ts
│ │ │ ├── useCsat.ts
│ │ │ ├── useDocumentDir.ts
│ │ │ ├── useIsAwaitingBotReply.ts
│ │ │ ├── useMessages.ts
│ │ │ ├── useModes.ts
│ │ │ ├── usePrimitiveState.ts
│ │ │ ├── useSessions.ts
│ │ │ ├── useUploadFiles.ts
│ │ │ ├── useWidgetRouter.ts
│ │ │ └── useWidgetTrigger.tsx
│ │ ├── index.ts
│ │ └── types
│ │ │ └── components.ts
│ ├── tsconfig.json
│ ├── vite-env.d.ts
│ ├── vite.config.ts
│ └── vitest.config.ts
├── react
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── app.dev.example.tsx
│ ├── embedded
│ │ └── index.tsx
│ ├── index.css
│ ├── index.dev.tsx
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src
│ │ ├── WidgetPopoverAnchor.tsx
│ │ ├── WidgetPopoverContent.tsx
│ │ ├── WidgetPopoverTrigger.tsx
│ │ ├── components
│ │ │ ├── AgentOrBotAvatar.tsx
│ │ │ ├── AttachmentPreview.tsx
│ │ │ ├── BotOrAgentMessage.tsx
│ │ │ ├── BotOrAgentMessageGroup.tsx
│ │ │ ├── CsatSurvey.tsx
│ │ │ ├── Dialoger.tsx
│ │ │ ├── GroupTimestamp.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── MemoizedReactMarkdown.tsx
│ │ │ ├── MightSolveUserIssueSuggestedReplies.tsx
│ │ │ ├── PoweredByOpen.tsx
│ │ │ ├── RichText.tsx
│ │ │ ├── SuggestedReplyButton.tsx
│ │ │ ├── UserMessage.tsx
│ │ │ ├── UserMessageGroup.tsx
│ │ │ ├── custom-components
│ │ │ │ ├── BotOrAgentMessageDefaultComponent.tsx
│ │ │ │ ├── FallbackDefaultComponent.tsx
│ │ │ │ ├── HandoffDefaultComponent.tsx
│ │ │ │ └── LoadingDefaultComponent.tsx
│ │ │ ├── lib
│ │ │ │ ├── DynamicIcon.tsx
│ │ │ │ ├── LoadingSpinner.tsx
│ │ │ │ ├── MotionDiv.tsx
│ │ │ │ ├── MotionDiv__VerticalReveal.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── tooltip.tsx
│ │ │ │ ├── utils
│ │ │ │ │ └── cn.ts
│ │ │ │ └── wobble.tsx
│ │ │ ├── special-components
│ │ │ │ ├── ChatBottomComponents.tsx
│ │ │ │ ├── HeaderBottomComponent.tsx
│ │ │ │ └── SessionResolvedComponent.tsx
│ │ │ └── svg
│ │ │ │ ├── ChatBubbleSvg.tsx
│ │ │ │ └── OpenLogoSvg.tsx
│ │ ├── hooks
│ │ │ ├── useCanvas.ts
│ │ │ ├── useIsSmallScreen.ts
│ │ │ ├── useSetWidgetSize.ts
│ │ │ ├── useSpecialComponentProps.ts
│ │ │ ├── useTheme.ts
│ │ │ ├── useTranslation.ts
│ │ │ └── useWidgetContentHeight.tsx
│ │ ├── index.tsx
│ │ ├── screens
│ │ │ ├── chat
│ │ │ │ ├── AdvancedInitialMessages.tsx
│ │ │ │ ├── ChatBannerItems.tsx
│ │ │ │ ├── ChatCanvas.tsx
│ │ │ │ ├── ChatFooter.tsx
│ │ │ │ ├── ChatFooterItems.tsx
│ │ │ │ ├── ChatMain.tsx
│ │ │ │ ├── InitialMessages.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ ├── sessions
│ │ │ │ └── index.tsx
│ │ │ └── welcome
│ │ │ │ └── index.tsx
│ │ └── utils
│ │ │ ├── data-component.ts
│ │ │ └── group-messages-by-type.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── vite-env.d.ts
│ ├── vite.config.ts
│ └── vitest.config.ts
└── tsconfig
│ ├── CHANGELOG.md
│ ├── package.json
│ └── tsconfig.base.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── turbo.json
└── vitest.config.ts
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "baseBranch": "main",
6 | "access": "public",
7 | "updateInternalDependencies": "patch"
8 | }
9 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | VITE_ORG_TOKEN=YOUR_ORG_TOKEN # get it from https://platform.open.cx/channels/configure/widget
2 | VITE_ORG_PUBLIC_API_TOKEN=
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 | *.rar
10 | node_modules
11 | dist
12 | dist-embed
13 | dist-lib
14 | dist-ssr
15 | *.local
16 |
17 | # Editor directories and files
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | .turbo
27 |
28 | .env
29 |
30 | # dev stuff
31 | app.dev.tsx
32 | dev
33 |
34 | tsconfig.tsbuildinfo
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "tabWidth": 2,
5 | "printWidth": 80
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "cSpell.words": ["Baskervville", "Convs", "orangered"],
4 | "typescript.tsdk": "node_modules/typescript/lib"
5 | }
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OpenCX Widget
2 |
3 | For all the available options, check [the documentation](https://docs.open.cx/widget/getting-started)
4 |
--------------------------------------------------------------------------------
/examples/basic-embed/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | opencx | Test
7 |
8 |
9 |
17 |
18 |
19 |
20 | Test only
21 |
22 |
23 |
--------------------------------------------------------------------------------
/examples/installable-embed/README.md:
--------------------------------------------------------------------------------
1 | # Installable Embed
2 |
3 | Another way to use the widget; instead of loading it from a CDN, install it with npm and put the script directly in HTML.
4 |
--------------------------------------------------------------------------------
/examples/installable-embed/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | opencx | Test
7 |
8 |
9 |
17 |
18 |
19 |
20 | Test only
21 |
22 |
23 |
--------------------------------------------------------------------------------
/examples/installable-embed/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-18",
3 | "version": "0.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "react-18",
9 | "version": "0.0.0",
10 | "dependencies": {
11 | "@opencx/widget": "^4.0.24"
12 | }
13 | },
14 | "node_modules/@opencx/widget": {
15 | "version": "4.0.24",
16 | "resolved": "https://registry.npmjs.org/@opencx/widget/-/widget-4.0.24.tgz",
17 | "integrity": "sha512-3nMfeOACi/awvg6H+iG4YN4W13pw1ObKHrZA7BliL9Vwz3+zlem59AiFWb9dV7VvO5YIj0WiFwZtUD/Hogfkaw=="
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/examples/installable-embed/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-18",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@opencx/widget": "^4.0.24"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/examples/react-18/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/examples/react-18/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config([
16 | globalIgnores(['dist']),
17 | {
18 | files: ['**/*.{ts,tsx}'],
19 | extends: [
20 | // Other configs...
21 |
22 | // Remove tseslint.configs.recommended and replace with this
23 | ...tseslint.configs.recommendedTypeChecked,
24 | // Alternatively, use this for stricter rules
25 | ...tseslint.configs.strictTypeChecked,
26 | // Optionally, add this for stylistic rules
27 | ...tseslint.configs.stylisticTypeChecked,
28 |
29 | // Other configs...
30 | ],
31 | languageOptions: {
32 | parserOptions: {
33 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
34 | tsconfigRootDir: import.meta.dirname,
35 | },
36 | // other options...
37 | },
38 | },
39 | ])
40 | ```
41 |
42 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
43 |
44 | ```js
45 | // eslint.config.js
46 | import reactX from 'eslint-plugin-react-x'
47 | import reactDom from 'eslint-plugin-react-dom'
48 |
49 | export default tseslint.config([
50 | globalIgnores(['dist']),
51 | {
52 | files: ['**/*.{ts,tsx}'],
53 | extends: [
54 | // Other configs...
55 | // Enable lint rules for React
56 | reactX.configs['recommended-typescript'],
57 | // Enable lint rules for React DOM
58 | reactDom.configs.recommended,
59 | ],
60 | languageOptions: {
61 | parserOptions: {
62 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
63 | tsconfigRootDir: import.meta.dirname,
64 | },
65 | // other options...
66 | },
67 | },
68 | ])
69 | ```
70 |
--------------------------------------------------------------------------------
/examples/react-18/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 | import { globalIgnores } from 'eslint/config'
7 |
8 | export default tseslint.config([
9 | globalIgnores(['dist']),
10 | {
11 | files: ['**/*.{ts,tsx}'],
12 | extends: [
13 | js.configs.recommended,
14 | tseslint.configs.recommended,
15 | reactHooks.configs['recommended-latest'],
16 | reactRefresh.configs.vite,
17 | ],
18 | languageOptions: {
19 | ecmaVersion: 2020,
20 | globals: globals.browser,
21 | },
22 | },
23 | ])
24 |
--------------------------------------------------------------------------------
/examples/react-18/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/react-18/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-18",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@opencx/widget-react": "^4.0.0",
14 | "react": "^18",
15 | "react-dom": "^18"
16 | },
17 | "devDependencies": {
18 | "@eslint/js": "^9.32.0",
19 | "@types/react": "^18",
20 | "@types/react-dom": "^18",
21 | "@vitejs/plugin-react-swc": "^3.11.0",
22 | "eslint": "^9.32.0",
23 | "eslint-plugin-react-hooks": "^5.2.0",
24 | "eslint-plugin-react-refresh": "^0.4.20",
25 | "globals": "^16.3.0",
26 | "typescript": "~5.8.3",
27 | "typescript-eslint": "^8.39.0",
28 | "vite": "^7.1.0"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/examples/react-18/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/react-18/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/examples/react-18/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import reactLogo from './assets/react.svg';
3 | import viteLogo from '/vite.svg';
4 | import './App.css';
5 | import { Widget } from '@opencx/widget-react';
6 |
7 | function App() {
8 | const [count, setCount] = useState(0);
9 |
10 | return (
11 | <>
12 |
20 | Vite + React
21 |
22 |
setCount((count) => count + 1)}>
23 | count is {count}
24 |
25 |
26 | Edit src/App.tsx and save to test HMR
27 |
28 |
29 |
30 | Click on the Vite and React logos to learn more
31 |
32 |
37 | >
38 | );
39 | }
40 |
41 | export default App;
42 |
--------------------------------------------------------------------------------
/examples/react-18/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/react-18/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | h1 {
34 | font-size: 3.2em;
35 | line-height: 1.1;
36 | }
37 |
38 | button {
39 | border-radius: 8px;
40 | border: 1px solid transparent;
41 | padding: 0.6em 1.2em;
42 | font-size: 1em;
43 | font-weight: 500;
44 | font-family: inherit;
45 | background-color: #1a1a1a;
46 | cursor: pointer;
47 | transition: border-color 0.25s;
48 | }
49 | button:hover {
50 | border-color: #646cff;
51 | }
52 | button:focus,
53 | button:focus-visible {
54 | outline: 4px auto -webkit-focus-ring-color;
55 | }
56 |
57 | @media (prefers-color-scheme: light) {
58 | :root {
59 | color: #213547;
60 | background-color: #ffffff;
61 | }
62 | a:hover {
63 | color: #747bff;
64 | }
65 | button {
66 | background-color: #f9f9f9;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/examples/react-18/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/examples/react-18/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/react-18/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2022",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "erasableSyntaxOnly": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/examples/react-18/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/examples/react-18/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2023",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "erasableSyntaxOnly": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/examples/react-18/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/examples/react-19/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/examples/react-19/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config([
16 | globalIgnores(['dist']),
17 | {
18 | files: ['**/*.{ts,tsx}'],
19 | extends: [
20 | // Other configs...
21 |
22 | // Remove tseslint.configs.recommended and replace with this
23 | ...tseslint.configs.recommendedTypeChecked,
24 | // Alternatively, use this for stricter rules
25 | ...tseslint.configs.strictTypeChecked,
26 | // Optionally, add this for stylistic rules
27 | ...tseslint.configs.stylisticTypeChecked,
28 |
29 | // Other configs...
30 | ],
31 | languageOptions: {
32 | parserOptions: {
33 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
34 | tsconfigRootDir: import.meta.dirname,
35 | },
36 | // other options...
37 | },
38 | },
39 | ])
40 | ```
41 |
42 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
43 |
44 | ```js
45 | // eslint.config.js
46 | import reactX from 'eslint-plugin-react-x'
47 | import reactDom from 'eslint-plugin-react-dom'
48 |
49 | export default tseslint.config([
50 | globalIgnores(['dist']),
51 | {
52 | files: ['**/*.{ts,tsx}'],
53 | extends: [
54 | // Other configs...
55 | // Enable lint rules for React
56 | reactX.configs['recommended-typescript'],
57 | // Enable lint rules for React DOM
58 | reactDom.configs.recommended,
59 | ],
60 | languageOptions: {
61 | parserOptions: {
62 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
63 | tsconfigRootDir: import.meta.dirname,
64 | },
65 | // other options...
66 | },
67 | },
68 | ])
69 | ```
70 |
--------------------------------------------------------------------------------
/examples/react-19/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 | import { globalIgnores } from 'eslint/config'
7 |
8 | export default tseslint.config([
9 | globalIgnores(['dist']),
10 | {
11 | files: ['**/*.{ts,tsx}'],
12 | extends: [
13 | js.configs.recommended,
14 | tseslint.configs.recommended,
15 | reactHooks.configs['recommended-latest'],
16 | reactRefresh.configs.vite,
17 | ],
18 | languageOptions: {
19 | ecmaVersion: 2020,
20 | globals: globals.browser,
21 | },
22 | },
23 | ])
24 |
--------------------------------------------------------------------------------
/examples/react-19/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/react-19/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-19",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@opencx/widget-react": "^4.0.0",
14 | "react": "^19.1.1",
15 | "react-dom": "^19.1.1"
16 | },
17 | "devDependencies": {
18 | "@eslint/js": "^9.32.0",
19 | "@types/react": "^19.1.9",
20 | "@types/react-dom": "^19.1.7",
21 | "@vitejs/plugin-react-swc": "^3.11.0",
22 | "eslint": "^9.32.0",
23 | "eslint-plugin-react-hooks": "^5.2.0",
24 | "eslint-plugin-react-refresh": "^0.4.20",
25 | "globals": "^16.3.0",
26 | "typescript": "~5.8.3",
27 | "typescript-eslint": "^8.39.0",
28 | "vite": "^7.1.0"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/examples/react-19/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/react-19/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/examples/react-19/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import reactLogo from './assets/react.svg';
3 | import viteLogo from '/vite.svg';
4 | import './App.css';
5 | import { Widget } from '@opencx/widget-react';
6 |
7 | function App() {
8 | const [count, setCount] = useState(0);
9 |
10 | return (
11 | <>
12 |
20 | Vite + React
21 |
22 |
setCount((count) => count + 1)}>
23 | count is {count}
24 |
25 |
26 | Edit src/App.tsx and save to test HMR
27 |
28 |
29 |
30 | Click on the Vite and React logos to learn more
31 |
32 |
37 | >
38 | );
39 | }
40 |
41 | export default App;
42 |
--------------------------------------------------------------------------------
/examples/react-19/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/react-19/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | h1 {
34 | font-size: 3.2em;
35 | line-height: 1.1;
36 | }
37 |
38 | button {
39 | border-radius: 8px;
40 | border: 1px solid transparent;
41 | padding: 0.6em 1.2em;
42 | font-size: 1em;
43 | font-weight: 500;
44 | font-family: inherit;
45 | background-color: #1a1a1a;
46 | cursor: pointer;
47 | transition: border-color 0.25s;
48 | }
49 | button:hover {
50 | border-color: #646cff;
51 | }
52 | button:focus,
53 | button:focus-visible {
54 | outline: 4px auto -webkit-focus-ring-color;
55 | }
56 |
57 | @media (prefers-color-scheme: light) {
58 | :root {
59 | color: #213547;
60 | background-color: #ffffff;
61 | }
62 | a:hover {
63 | color: #747bff;
64 | }
65 | button {
66 | background-color: #f9f9f9;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/examples/react-19/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.tsx'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/examples/react-19/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/react-19/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2022",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "erasableSyntaxOnly": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/examples/react-19/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/examples/react-19/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2023",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "erasableSyntaxOnly": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/examples/react-19/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/widget-monorepo-root",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "packageManager": "pnpm@10.12.1",
7 | "scripts": {
8 | "/* ------------------------ SETUP ----------------------- */": "",
9 | "gen:sdk": "pnpm -F @opencx/widget-core gen:sdk",
10 | "clean": "turbo clean && rm -rf node_modules .turbo",
11 | "clean:dist": "turbo clean:dist && rm -rf .turbo",
12 | "build": "turbo build",
13 | "type-check": "turbo type-check",
14 | "test": "turbo test",
15 | "/* --------------------- DEVELOPMENT -------------------- */": "",
16 | "dev:prepare": "dotenv -- turbo dev:prepare --env-mode",
17 | "dev": "dotenv -- turbo dev --env-mode",
18 | "/* --------------------- PUBLISHING --------------------- */": "",
19 | "x": "pnpm clean:dist && pnpm build && pnpm type-check && pnpm test",
20 | "cs:pre": "changeset pre enter prerelease",
21 | "cs:pre:exit": "changeset pre exit",
22 | "cs": "changeset",
23 | "csv": "changeset version",
24 | "csp": "pnpm x && changeset publish"
25 | },
26 | "devDependencies": {
27 | "@changesets/cli": "^2.27.9",
28 | "@types/node": "^20.14.8",
29 | "@vitejs/plugin-react-swc": "^4.0.0",
30 | "dotenv-cli": "^10.0.0",
31 | "jsdom": "^25.0.1",
32 | "prettier": "^3.5.3",
33 | "turbo": "^2.5.5",
34 | "typescript": "^5.5.4",
35 | "vite": "^5.4.2",
36 | "vite-plugin-dts": "4.0.3",
37 | "vite-plugin-externalize-deps": "^0.9.0",
38 | "vite-tsconfig-paths": "^5.0.1",
39 | "vitest": "^3.0.2"
40 | },
41 | "pnpm": {
42 | "overrides": {
43 | "mdast-util-gfm-autolink-literal": "2.0.0"
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/core/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @opencx/widget-core
2 |
3 | ## 4.0.30
4 |
5 | ### Patch Changes
6 |
7 | - add `WidgetConfig.specialComponents.headerBottom`
8 |
9 | ## 4.0.29
10 |
11 | ### Patch Changes
12 |
13 | - fix avatar url for bot persistable initial messages
14 |
15 | ## 4.0.28
16 |
17 | ### Patch Changes
18 |
19 | - handle unexpected AI errors more gracefully
20 |
21 | ## 4.0.27
22 |
23 | ### Patch Changes
24 |
25 | - fix loading state when awaiting bot reply
26 |
27 | ## 4.0.26
28 |
29 | ### Patch Changes
30 |
31 | - enhance parsing sessionCustomData
32 |
33 | ## 4.0.25
34 |
35 | ### Patch Changes
36 |
37 | - add translations for Danish, Finnish, Italian, Norwegian, Romanian and Swedish
38 |
39 | ## 4.0.24
40 |
41 | ### Patch Changes
42 |
43 | - make @opencx/widget dep free
44 | - eb2b209: cleanup @opencx/widget deps
45 |
46 | ## 4.0.24-prerelease.0
47 |
48 | ### Patch Changes
49 |
50 | - cleanup @opencx/widget deps
51 |
52 | ## 4.0.23
53 |
54 | ### Patch Changes
55 |
56 | - Add polish translations
57 |
58 | ## 4.0.22
59 |
60 | ### Patch Changes
61 |
62 | - remove incompatible regex with safari<16
63 |
64 | ## 4.0.21
65 |
66 | ### Patch Changes
67 |
68 | - do not hardcode non verified name as anonymous
69 |
70 | ## 4.0.20
71 |
72 | ### Patch Changes
73 |
74 | - add chatBottomComponents
75 |
76 | ## 4.0.19
77 |
78 | ### Patch Changes
79 |
80 | - add WidgetConfig.chatFooterItems
81 |
82 | ## 4.0.18
83 |
84 | ### Patch Changes
85 |
86 | - overridable translations
87 |
88 | ## 4.0.17
89 |
90 | ### Patch Changes
91 |
92 | - publish embed under `@opencx/widget` just like it was before
93 |
94 | ## 4.0.16
95 |
96 | ### Patch Changes
97 |
98 | - enrich props for special components
99 |
100 | ## 4.0.15
101 |
102 | ### Patch Changes
103 |
104 | - remove dependency on `prelude` endpoint
105 |
106 | ## 4.0.14
107 |
108 | ### Patch Changes
109 |
110 | - fix special component when session is resolved
111 |
112 | ## 4.0.13
113 |
114 | ### Patch Changes
115 |
116 | - add `WidgetConfig.specialComponents`
117 |
118 | ## 4.0.12
119 |
120 | ### Patch Changes
121 |
122 | - fix fetching csat requested system message
123 |
124 | ## 4.0.11
125 |
126 | ### Patch Changes
127 |
128 | - add csat survey
129 |
130 | ## 4.0.10
131 |
132 | ### Patch Changes
133 |
134 | - persist initial messages in db
135 |
136 | ## 4.0.9
137 |
138 | ### Patch Changes
139 |
140 | - fix portal and dialogs
141 |
142 | ## 4.0.8
143 |
144 | ### Patch Changes
145 |
146 | - add WidgetConfig.chatBannerItems
147 |
148 | ## 4.0.7
149 |
150 | ### Patch Changes
151 |
152 | - add timestamps for message groups
153 |
154 | ## 4.0.6
155 |
156 | ### Patch Changes
157 |
158 | - read version from local package.json
159 |
160 | ## 4.0.5
161 |
162 | ### Patch Changes
163 |
164 | - bump up version
165 |
166 | ## 4.0.4
167 |
168 | ### Patch Changes
169 |
170 | - fix portal position
171 |
172 | ## 4.0.3
173 |
174 | ### Patch Changes
175 |
176 | - add rtl support
177 |
178 | ## 4.0.2
179 |
180 | ### Patch Changes
181 |
182 | - add `borderRadius: 100%` for widget iframe
183 |
184 | ## 4.0.1
185 |
186 | ### Patch Changes
187 |
188 | - enable externally controlling the `close-widget` header button
189 |
190 | ## 4.0.0
191 |
192 | ### Major Changes
193 |
194 | - monorepo setup
195 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 | # OpenCX Widget Core
2 |
3 | A framework-agnostic vanilla-JS data layer engine.
4 |
5 | For more information, check [the documentation](https://docs.open.cx/widget/getting-started)
6 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/widget-core",
3 | "private": false,
4 | "version": "4.0.30",
5 | "type": "module",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/openchatai/widget"
9 | },
10 | "scripts": {
11 | "clean": "rm -rf node_modules dist .turbo",
12 | "clean:dist": "rm -rf dist .turbo",
13 | "build": "vite build",
14 | "gen:sdk": "pnpm dlx openapi-typescript http://localhost:8080/widget-api-yaml -o ./src/api/schema.ts && prettier --write ./src/api/schema.ts",
15 | "test": "vitest run",
16 | "type-check": "tsc --noEmit",
17 | "lint": "eslint .",
18 | "format": "prettier --write ."
19 | },
20 | "files": [
21 | "dist"
22 | ],
23 | "exports": {
24 | ".": {
25 | "types": "./dist/index.d.ts",
26 | "import": "./dist/index.js",
27 | "require": "./dist/index.cjs"
28 | }
29 | },
30 | "dependencies": {
31 | "lodash.isequal": "^4.5.0",
32 | "openapi-fetch": "^0.13.4",
33 | "uuid": "^11.0.4"
34 | },
35 | "devDependencies": {
36 | "@opencx/eslint-config": "workspace:*",
37 | "@opencx/tsconfig": "workspace:*",
38 | "@types/lodash.isequal": "^4.5.8",
39 | "lucide": "^0.525.0",
40 | "openapi-typescript": "^7.5.2"
41 | },
42 | "peerDependencies": {
43 | "@types/react": ">=18 <20",
44 | "@types/react-dom": ">=18 <20"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/api-caller.mock.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 | import { TestUtils } from './test-utils';
3 |
4 | vi.mock(import('../api/api-caller'), () => {
5 | const MockApiCaller = vi.fn();
6 |
7 | // Default mock return values for all methods
8 | for (const [name, mock] of Object.entries(TestUtils.mock.ApiCaller)) {
9 | mock(MockApiCaller, undefined);
10 | vi.spyOn(MockApiCaller.prototype, name);
11 | }
12 |
13 | return {
14 | ApiCaller: MockApiCaller,
15 | };
16 | });
17 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/context/contact/auth/auto-create-unverified-anonymous.spec.ts:
--------------------------------------------------------------------------------
1 | import '../../../api-caller.mock';
2 |
3 | import { ApiCaller } from '../../../../api/api-caller';
4 | import { WidgetCtx } from '../../../../context/widget.ctx';
5 | import { TestUtils } from '../../../test-utils';
6 |
7 | suite('', () => {
8 | test('', async () => {
9 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(0);
10 | const widgetCtx = await WidgetCtx.initialize({ config: { token: '' } });
11 |
12 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
13 | expect(widgetCtx.contactCtx.state.get().contact).toBeNull();
14 |
15 | await TestUtils.sleep(100);
16 |
17 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(1);
18 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBeDefined();
19 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBeTruthy();
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/context/contact/auth/auto-create-unverified-with-user-data-provided-by-org.spec.ts:
--------------------------------------------------------------------------------
1 | import '../../../api-caller.mock';
2 |
3 | import { ApiCaller } from '../../../../api/api-caller';
4 | import { WidgetCtx } from '../../../../context/widget.ctx';
5 | import { TestUtils } from '../../../test-utils';
6 |
7 | suite('', () => {
8 | test('', async () => {
9 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(0);
10 | const widgetCtx = await WidgetCtx.initialize({
11 | config: { token: '', user: { data: { email: 'test@email.com' } } },
12 | });
13 |
14 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
15 | expect(widgetCtx.contactCtx.state.get().contact).toBeNull();
16 |
17 | await TestUtils.sleep(100);
18 |
19 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(1);
20 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBeDefined();
21 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBeTruthy();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/context/contact/auth/manually-create-unverified-with-user-data-provided-by-user.spec.ts:
--------------------------------------------------------------------------------
1 | import '../../../api-caller.mock';
2 |
3 | import { ApiCaller } from '../../../../api/api-caller';
4 | import { WidgetCtx } from '../../../../context/widget.ctx';
5 | import { TestUtils } from '../../../test-utils';
6 |
7 | suite('', () => {
8 | test('', async () => {
9 | const widgetCtx = await WidgetCtx.initialize({
10 | config: { token: '', collectUserData: true },
11 | });
12 |
13 | // Assert that the contact was not auto created
14 | await TestUtils.sleep(100);
15 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(0);
16 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeTruthy();
17 | expect(widgetCtx.contactCtx.state.get().contact).toBeNull();
18 |
19 | // Mimic user inputting a name and email
20 | await widgetCtx.contactCtx.createUnverifiedContact({
21 | non_verified_name: 'some-name',
22 | email: 'test@email.com',
23 | });
24 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(1);
25 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
26 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBeDefined();
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/context/contact/auth/with-secure-token.spec.ts:
--------------------------------------------------------------------------------
1 | import '../../../api-caller.mock';
2 |
3 | import { ApiCaller } from '../../../../api/api-caller';
4 | import { WidgetCtx } from '../../../../context/widget.ctx';
5 | import { TestUtils } from '../../../test-utils';
6 |
7 | suite('', () => {
8 | test('', async () => {
9 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(0);
10 | const widgetCtx = await WidgetCtx.initialize({
11 | config: { token: '', user: { token: 'xyz', externalId: 'abc' } },
12 | });
13 |
14 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
15 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBe('xyz');
16 | expect(widgetCtx.contactCtx.state.get().contact?.externalId).toBe('abc');
17 |
18 | await TestUtils.sleep(100);
19 |
20 | expect(ApiCaller.prototype.createUnverifiedContact).toBeCalledTimes(0);
21 | expect(widgetCtx.contactCtx.state.get().contact?.token).toBe('xyz');
22 | expect(widgetCtx.contactCtx.state.get().contact?.externalId).toBe('abc');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/context/contact/should-collect-data.spec.ts:
--------------------------------------------------------------------------------
1 | import '../../api-caller.mock';
2 |
3 | import { WidgetCtx } from '../../../context/widget.ctx';
4 | import { TestUtils } from '../../test-utils';
5 |
6 | suite.concurrent('ContactCtx.shouldCollectData', () => {
7 | it('Should be always false if not defined in config', async () => {
8 | const widgetCtx = await WidgetCtx.initialize({ config: { token: '' } });
9 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
10 | // Wait until auto-create finishes
11 | await TestUtils.sleep(100);
12 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
13 | });
14 |
15 | it('Should be always false if set to false in config', async () => {
16 | const widgetCtx = await WidgetCtx.initialize({
17 | config: { token: '', collectUserData: false },
18 | });
19 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
20 | // Wait until auto-auth finishes
21 | await TestUtils.sleep(100);
22 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
23 | });
24 |
25 | suite('if set to `true` in config', () => {
26 | it('Should remain `true` if no user is provided', async () => {
27 | const widgetCtx = await WidgetCtx.initialize({
28 | config: { token: '', collectUserData: true },
29 | });
30 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeTruthy();
31 |
32 | // Wait until auto-auth finishes
33 | await TestUtils.sleep(100);
34 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeTruthy();
35 | });
36 |
37 | it('Should be initially `false` and remain `false` if user token is provided', async () => {
38 | const widgetCtx = await WidgetCtx.initialize({
39 | config: {
40 | token: '',
41 | collectUserData: true,
42 | user: { token: 'some-token' },
43 | },
44 | });
45 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
46 |
47 | // Wait until auto-auth finishes
48 | await TestUtils.sleep(100);
49 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
50 | });
51 |
52 | it('Should become `false` after auto-auth (user data)', async () => {
53 | const widgetCtx = await WidgetCtx.initialize({
54 | config: {
55 | token: '',
56 | collectUserData: true,
57 | user: { data: { email: 'test@email.com' } },
58 | },
59 | });
60 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeTruthy();
61 |
62 | // Wait until auto-auth finishes
63 | await TestUtils.sleep(100);
64 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
65 | });
66 |
67 | it('Should become `false` after manual auth (user data)', async () => {
68 | const widgetCtx = await WidgetCtx.initialize({
69 | config: {
70 | token: '',
71 | collectUserData: true,
72 | },
73 | });
74 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeTruthy();
75 |
76 | // Mimic inputting name and email
77 | await widgetCtx.contactCtx.createUnverifiedContact({
78 | email: 'test@email.com',
79 | non_verified_name: 'some-name',
80 | });
81 |
82 | expect(widgetCtx.contactCtx.shouldCollectData()).toBeFalsy();
83 | });
84 | });
85 |
86 | // TODO add tests for auth persistence when `storage` is provided to the widget context
87 | });
88 |
--------------------------------------------------------------------------------
/packages/core/src/__tests__/utils/Poller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Poller } from '../../utils/Poller';
2 |
3 | suite(Poller.name, () => {
4 | beforeEach(() => {
5 | vi.useFakeTimers();
6 | });
7 |
8 | afterEach(() => {
9 | vi.useRealTimers();
10 | });
11 |
12 | it('should fire the callback instantly the first time', async () => {
13 | const poller = new Poller();
14 | const cb = vi.fn();
15 | poller.startPolling(cb, 1000);
16 |
17 | expect(cb).toHaveBeenCalledOnce();
18 |
19 | poller.reset();
20 | });
21 |
22 | it('should poll and respect the interval', async () => {
23 | const INTERVAL = 1000;
24 | const poller = new Poller();
25 | const cb = vi.fn();
26 | poller.startPolling(cb, INTERVAL);
27 |
28 | // vi.advanceTimersByTime(100);
29 | expect(cb).toHaveBeenCalledTimes(1);
30 |
31 | await vi.advanceTimersByTimeAsync(INTERVAL);
32 | expect(cb).toHaveBeenCalledTimes(2);
33 | await vi.advanceTimersByTimeAsync(INTERVAL);
34 | expect(cb).toHaveBeenCalledTimes(3);
35 |
36 | poller.reset();
37 | });
38 |
39 | it('should stop polling if reset', async () => {
40 | const INTERVAL = 1000;
41 | const poller = new Poller();
42 | const cb = vi.fn();
43 | poller.startPolling(cb, INTERVAL);
44 |
45 | expect(cb).toHaveBeenCalledTimes(1);
46 | await vi.advanceTimersByTimeAsync(INTERVAL);
47 | expect(cb).toHaveBeenCalledTimes(2);
48 |
49 | // Stop polling
50 | poller.reset();
51 | // Make sure the callback is not called after the reset
52 | await vi.advanceTimersByTimeAsync(INTERVAL);
53 | expect(cb).toHaveBeenCalledTimes(2);
54 | await vi.advanceTimersByTimeAsync(INTERVAL);
55 | expect(cb).toHaveBeenCalledTimes(2);
56 |
57 | // Start polling again with a new callback
58 | const cb2 = vi.fn();
59 | poller.startPolling(cb2, INTERVAL);
60 | expect(cb2).toHaveBeenCalledTimes(1);
61 |
62 | await vi.advanceTimersByTimeAsync(INTERVAL);
63 | expect(cb2).toHaveBeenCalledTimes(2);
64 |
65 | // Expect the first callback to not have been called after the second `startPolling`
66 | expect(cb).toHaveBeenCalledTimes(2);
67 |
68 | poller.reset();
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/packages/core/src/api/client.ts:
--------------------------------------------------------------------------------
1 | import createClient, { type Middleware } from 'openapi-fetch';
2 | import type { paths } from './schema';
3 | import type { components } from './schema';
4 |
5 | type Options = {
6 | baseUrl: string;
7 | onRequest?: Middleware['onRequest'];
8 | onResponse?: Middleware['onResponse'];
9 | onError?: Middleware['onError'];
10 | };
11 |
12 | const defaultOnError: Middleware['onError'] = (onErrorOptions) => {
13 | console.log(onErrorOptions.error);
14 | };
15 |
16 | export const basicClient = (options: Options) => {
17 | const client = createClient({
18 | baseUrl: options.baseUrl,
19 | });
20 |
21 | const middlewares: Middleware = {
22 | onRequest: options.onRequest,
23 | onResponse: options.onResponse,
24 | onError: options.onError || defaultOnError,
25 | };
26 |
27 | client.use(middlewares);
28 | return client;
29 | };
30 |
31 | export type Endpoint = keyof paths;
32 | export type Dto = components['schemas'];
33 |
--------------------------------------------------------------------------------
/packages/core/src/context/csat.ctx.ts:
--------------------------------------------------------------------------------
1 | import type { ApiCaller } from '../api/api-caller';
2 | import type { Dto } from '../api/client';
3 | import type { SafeOmit } from '../types/helpers';
4 | import type { WidgetConfig } from '../types/widget-config';
5 | import { genUuid } from '../utils/uuid';
6 | import type { MessageCtx } from './message.ctx';
7 | import type { SessionCtx } from './session.ctx';
8 |
9 | export class CsatCtx {
10 | private config: WidgetConfig;
11 | private api: ApiCaller;
12 | private sessionCtx: SessionCtx;
13 | private messageCtx: MessageCtx;
14 |
15 | constructor({
16 | config,
17 | api,
18 | sessionCtx,
19 | messageCtx,
20 | }: {
21 | config: WidgetConfig;
22 | api: ApiCaller;
23 | sessionCtx: SessionCtx;
24 | messageCtx: MessageCtx;
25 | }) {
26 | this.config = config;
27 | this.api = api;
28 | this.sessionCtx = sessionCtx;
29 | this.messageCtx = messageCtx;
30 | }
31 |
32 | submitCsat = async (
33 | body: Pick,
34 | ) => {
35 | const currentSessionId = this.sessionCtx.sessionState.get().session?.id;
36 | if (!currentSessionId) {
37 | return { data: null, error: 'No session id found' };
38 | }
39 |
40 | const uuid = genUuid();
41 | this.messageCtx.state.setPartial({
42 | messages: [
43 | ...this.messageCtx.state.get().messages,
44 | {
45 | id: uuid,
46 | type: 'SYSTEM',
47 | subtype: 'csat_submitted',
48 | timestamp: new Date().toISOString(),
49 | data: {
50 | payload: {
51 | score: body.score,
52 | feedback: body.feedback,
53 | },
54 | },
55 | },
56 | ],
57 | });
58 |
59 | const { data, error } = await this.api.submitCsat({
60 | ...body,
61 | system_message_uuid: uuid,
62 | session_id: currentSessionId,
63 | });
64 | return { data, error };
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/packages/core/src/context/router.ctx.ts:
--------------------------------------------------------------------------------
1 | import type { WidgetConfig } from '../types/widget-config';
2 | import { PrimitiveState } from '../utils/PrimitiveState';
3 | import type { ContactCtx } from './contact.ctx';
4 | import type { SessionCtx } from './session.ctx';
5 | import type { WidgetCtx } from './widget.ctx';
6 |
7 | export type ScreenU =
8 | | /** A welcome screen to collect user data. Useful in public non-logged-in environments */
9 | 'welcome'
10 | /** Show a list of the user's previous sessions */
11 | | 'sessions'
12 | /** Self-explanatory */
13 | | 'chat';
14 |
15 | type RouterState = {
16 | screen: ScreenU;
17 | };
18 |
19 | export class RouterCtx {
20 | state: PrimitiveState;
21 |
22 | private config: WidgetConfig;
23 | private contactCtx: ContactCtx;
24 | private sessionCtx: SessionCtx;
25 | private resetChat: WidgetCtx['resetChat'];
26 |
27 | constructor({
28 | config,
29 | contactCtx,
30 | sessionCtx,
31 | resetChat,
32 | }: {
33 | config: WidgetConfig;
34 | contactCtx: ContactCtx;
35 | sessionCtx: SessionCtx;
36 | resetChat: WidgetCtx['resetChat'];
37 | }) {
38 | this.config = config;
39 | this.contactCtx = contactCtx;
40 | this.sessionCtx = sessionCtx;
41 | this.resetChat = resetChat;
42 | this.state = new PrimitiveState({
43 | screen: this.contactCtx.shouldCollectData()
44 | ? 'welcome'
45 | : this.config.router?.chatScreenOnly
46 | ? 'chat'
47 | : 'sessions',
48 | });
49 |
50 | this.registerRoutingListener();
51 | }
52 |
53 | private registerRoutingListener = () => {
54 | this.contactCtx.state.subscribe(({ contact }) => {
55 | // Auto navigate to sessions screen after collecting user data
56 | if (contact?.token && this.state.get().screen === 'welcome') {
57 | this.state.setPartial({
58 | screen: this.config.router?.chatScreenOnly ? 'chat' : 'sessions',
59 | });
60 | }
61 | });
62 |
63 | this.sessionCtx.sessionsState.subscribe(
64 | ({ isInitialFetchLoading, data }) => {
65 | if (
66 | this.config.router?.chatScreenOnly &&
67 | // Do not route to a chat if we are currently inside one already
68 | // This also applies to newly created sessions; the new session will be in `sessionState` before it is refreshed and included in `sessionsState`
69 | !this.sessionCtx.sessionState.get().session?.id
70 | ) {
71 | const mostRecentOpenSessionId = data.find((s) => s.isOpened)?.id;
72 | return mostRecentOpenSessionId
73 | ? this.toChatScreen(mostRecentOpenSessionId)
74 | : undefined;
75 | }
76 |
77 | if (data.length) return;
78 | if (this.config.router?.goToChatIfNoSessions === false) return;
79 |
80 | // Auto navigate to chat screen if contact has no previous sessions
81 | if (!isInitialFetchLoading && this.state.get().screen !== 'chat') {
82 | this.toChatScreen();
83 | }
84 | },
85 | );
86 | };
87 |
88 | toSessionsScreen = () => {
89 | this.resetChat();
90 | this.state.setPartial({ screen: 'sessions' });
91 | };
92 |
93 | /**
94 | * @param sessionId The ID of the session to open, or `undefined` if it is a new chat session
95 | */
96 | toChatScreen = (sessionId?: string) => {
97 | this.resetChat();
98 |
99 | if (sessionId) {
100 | const session = this.sessionCtx.sessionsState
101 | .get()
102 | .data.find((s) => s.id === sessionId);
103 | // Do not navigate if session is not found (this shouldn't happen, unless a wrong ID is passed)
104 | if (!session) return;
105 | this.sessionCtx.sessionState.setPartial({ session });
106 | }
107 |
108 | this.state.setPartial({ screen: 'chat' });
109 | };
110 | }
111 |
--------------------------------------------------------------------------------
/packages/core/src/context/storage.ctx.ts:
--------------------------------------------------------------------------------
1 | import type { ExternalStorage } from '../types/external-storage';
2 | import type { WidgetConfig } from '../types/widget-config';
3 |
4 | export class StorageCtx {
5 | private storage: ExternalStorage;
6 | private config: WidgetConfig;
7 |
8 | private KEYS = {
9 | contactToken: (orgToken: string) =>
10 | `opencx-widget:org-token-${orgToken}:contact-token`,
11 | externalContactId: (orgToken: string) =>
12 | `opencx-widget:org-token-${orgToken}:external-contact-id`,
13 | };
14 |
15 | constructor({
16 | storage,
17 | config,
18 | }: {
19 | storage: ExternalStorage;
20 | config: WidgetConfig;
21 | }) {
22 | this.storage = storage;
23 | this.config = config;
24 | }
25 |
26 | setContactToken = async (token: string) => {
27 | await this.storage.set(this.KEYS.contactToken(this.config.token), token);
28 | };
29 | getContactToken = async () => {
30 | return this.storage.get(this.KEYS.contactToken(this.config.token));
31 | };
32 |
33 | setExternalContactId = async (id: string) => {
34 | await this.storage.set(this.KEYS.externalContactId(this.config.token), id);
35 | };
36 | getExternalContactId = async () => {
37 | return this.storage.get(this.KEYS.externalContactId(this.config.token));
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export type { AgentOrBotType } from './types/agent-or-bot';
2 | export type { SafeExtract, SafeOmit, StringOrLiteral } from './types/helpers';
3 | export type {
4 | LiteralWidgetComponentKey,
5 | WidgetComponentKey,
6 | WidgetUserMessage,
7 | WidgetAgentMessage,
8 | WidgetAiMessage,
9 | WidgetSystemMessage__StateCheckpoint,
10 | WidgetSystemMessage__CsatRequested,
11 | WidgetSystemMessage__CsatSubmitted,
12 | WidgetSystemMessageU,
13 | WidgetMessageU,
14 | } from './types/messages';
15 | export type {
16 | MessageAttachmentType,
17 | MessageDto,
18 | SendMessageDto,
19 | SendMessageOutputDto,
20 | ResolveSessionDto,
21 | SessionDto,
22 | VoteInputDto,
23 | VoteOutputDto,
24 | ActionCallDto,
25 | ModeDto,
26 | } from './types/dtos';
27 | export type {
28 | WidgetConfig,
29 | HeaderButtonU,
30 | ModeComponent,
31 | ModeComponentProps,
32 | SpecialComponent,
33 | SpecialComponentProps,
34 | } from './types/widget-config';
35 | export type { ExternalStorage } from './types/external-storage';
36 | export type { OpenCxComponentNameU } from './types/component-name';
37 | export type { IconNameU } from './types/icons';
38 |
39 | export { WidgetCtx } from './context/widget.ctx';
40 | export type { ContactCtx } from './context/contact.ctx';
41 | export type { SessionCtx } from './context/session.ctx';
42 | export type { MessageCtx } from './context/message.ctx';
43 | export type { RouterCtx, ScreenU } from './context/router.ctx';
44 | export type { CsatCtx } from './context/csat.ctx';
45 |
46 | export { PrimitiveState } from './utils/PrimitiveState';
47 | export { isExhaustive } from './utils/is-exhaustive';
48 |
49 | export {
50 | type Language,
51 | type TranslationInterface,
52 | type TranslationKeyU,
53 | getTranslation,
54 | isSupportedLanguage,
55 | } from './translation';
56 |
--------------------------------------------------------------------------------
/packages/core/src/translation/ar.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const ArabicLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'اكتب رسالة...',
5 | your_issue_has_been_resolved: 'تم حل مشكلتك!',
6 | new_conversation: 'محادثة جديدة',
7 | welcome_screen_title: 'مرحبًا بك في دردشة الدعم الخاصة بنا',
8 | welcome_screen_description:
9 | 'نحن هنا للمساعدة! ابدأ محادثة وسنرد عليك في أقرب وقت ممكن.',
10 | your_name_placeholder: 'اسمك',
11 | your_email_placeholder: 'عنوان بريدك الإلكتروني',
12 | start_chat_button: 'تحدث إلى الدعم',
13 | start_chat_button_loading: 'جاري الاتصال...',
14 | i_need_more_help: 'أحتاج المزيد من المساعدة',
15 | this_was_helpful: 'كان هذا مفيدًا',
16 | optional: 'اختياري',
17 | no_conversations_yet: 'لا يوجد محادثات',
18 | back_to_conversations: 'العودة إلى المحادثات',
19 | closed_conversations: 'المحادثات المغلقة',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/da.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const DanishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Skriv en besked...',
5 | your_issue_has_been_resolved: 'Dit problem er løst!',
6 | new_conversation: 'Ny samtale',
7 | welcome_screen_title: 'Velkommen til vores support chat',
8 | welcome_screen_description:
9 | 'Vi er her for at hjælpe! Start en samtale, og vi vender tilbage til dig så hurtigt som muligt.',
10 | your_name_placeholder: 'Dit navn',
11 | your_email_placeholder: 'Din e-mailadresse',
12 | start_chat_button: 'Tal med support',
13 | start_chat_button_loading: 'Forbinder...',
14 | i_need_more_help: 'Jeg har brug for mere hjælp',
15 | this_was_helpful: 'Dette var nyttigt',
16 | optional: 'Valgfrit',
17 | no_conversations_yet: 'Ingen samtaler endnu',
18 | back_to_conversations: 'Tilbage til samtaler',
19 | closed_conversations: 'Lukkede samtaler',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/de.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const GermanLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Nachricht schreiben...',
5 | your_issue_has_been_resolved: 'Ihr Problem wurde gelöst!',
6 | new_conversation: 'Neue Konversation',
7 | welcome_screen_title: 'Willkommen in unserem Support-Chat',
8 | welcome_screen_description:
9 | 'Wir sind hier, um zu helfen! Beginnen Sie ein Gesprách und wir werden so schnell wie mogelijk antworten.',
10 | your_name_placeholder: 'Ihr Name',
11 | your_email_placeholder: 'Ihre E-Mail-Adresse',
12 | start_chat_button: 'Mit dem Support sprechen',
13 | start_chat_button_loading: 'Verbindung wird hergestellt...',
14 | i_need_more_help: 'Ich brauche weitere Hilfe',
15 | this_was_helpful: 'Dies war hilfreich',
16 | optional: 'Optional',
17 | no_conversations_yet: 'noch keine Gespräche',
18 | back_to_conversations: 'Zurück zur Konversationen',
19 | closed_conversations: 'Geschlossene Konversationen',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/en.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const EnglishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Write a message...',
5 | your_issue_has_been_resolved: 'Your issue has been resolved!',
6 | new_conversation: 'New conversation',
7 | welcome_screen_title: 'Welcome to our support chat',
8 | welcome_screen_description:
9 | "We're here to help! Start a conversation and we'll get back to you as soon as possible.",
10 | your_name_placeholder: 'Your name',
11 | your_email_placeholder: 'Your email address',
12 | start_chat_button: 'Talk to support',
13 | start_chat_button_loading: 'Connecting...',
14 | i_need_more_help: 'I need more help',
15 | this_was_helpful: 'This was helpful',
16 | optional: 'Optional',
17 | no_conversations_yet: 'No conversations yet',
18 | back_to_conversations: 'Back to conversations',
19 | closed_conversations: 'Closed conversations',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/es.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const SpanishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Escribe un mensaje...',
5 | your_issue_has_been_resolved: '¡Tu problema fue resuelto!',
6 | new_conversation: 'Nueva conversación',
7 | welcome_screen_title: 'Bienvenido a nuestro chat de soporte',
8 | welcome_screen_description:
9 | '¡Estamos aquí para ayudarte! Inicia una conversación y responderemos lo antes posible.',
10 | your_name_placeholder: 'Tu nombre',
11 | your_email_placeholder: 'Tu correo electrónico',
12 | start_chat_button: 'Hablar con soporte',
13 | start_chat_button_loading: 'Conectando...',
14 | i_need_more_help: 'Necesito más ayuda',
15 | this_was_helpful: 'Esto fue útil',
16 | optional: 'Opcional',
17 | no_conversations_yet: 'Sin conversaciones aún',
18 | back_to_conversations: 'Volver a conversaciones',
19 | closed_conversations: 'Conversaciones cerradas',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/fi.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const FinnishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Kirjoita viesti...',
5 | your_issue_has_been_resolved: 'Ongelmasi on ratkaistu!',
6 | new_conversation: 'Uusi keskustelu',
7 | welcome_screen_title: 'Tervetuloa tukichattiin',
8 | welcome_screen_description:
9 | 'Olemme täällä auttamassa! Aloita keskustelu ja palaamme sinulle mahdollisimman pian.',
10 | your_name_placeholder: 'Nimesi',
11 | your_email_placeholder: 'Sähköpostiosoitteesi',
12 | start_chat_button: 'Keskustele tuen kanssa',
13 | start_chat_button_loading: 'Yhdistetään...',
14 | i_need_more_help: 'Tarvitsen lisää apua',
15 | this_was_helpful: 'Tämä oli hyödyllistä',
16 | optional: 'Valinnainen',
17 | no_conversations_yet: 'Ei vielä keskusteluja',
18 | back_to_conversations: 'Takaisin keskusteluihin',
19 | closed_conversations: 'Suljetut keskustelut',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/fr.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const FrenchLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Écrivez un message...',
5 | your_issue_has_been_resolved: 'Votre problème a été résolu !',
6 | new_conversation: 'Nouvelle conversation',
7 | welcome_screen_title: 'Bienvenue dans notre chat de support',
8 | welcome_screen_description:
9 | 'Nous sommes là pour vous aider ! Commencez une conversation et nous vous répondrons dès que possible.',
10 | your_name_placeholder: 'Votre nom',
11 | your_email_placeholder: 'Votre adresse e-mail',
12 | start_chat_button: 'Parler au support',
13 | start_chat_button_loading: 'Connexion...',
14 | i_need_more_help: "Je besoin d'aide plus",
15 | this_was_helpful: "C'était utile",
16 | optional: 'Optionnel',
17 | no_conversations_yet: 'Aucune conversation pour le moment',
18 | back_to_conversations: 'Retour aux conversations',
19 | closed_conversations: 'Conversations fermées',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/index.ts:
--------------------------------------------------------------------------------
1 | import type { WidgetConfig } from '../types/widget-config';
2 | import { ArabicLanguage } from './ar';
3 | import { DanishLanguage } from './da';
4 | import { GermanLanguage } from './de';
5 | import { EnglishLanguage } from './en';
6 | import { SpanishLanguage } from './es';
7 | import { FinnishLanguage } from './fi';
8 | import { FrenchLanguage } from './fr';
9 | import { ItalianLanguage } from './it';
10 | import { DutchLanguage } from './nl';
11 | import { NorwegianLanguage } from './no';
12 | import { PolishLanguage } from './pl';
13 | import { PortugueseLanguage } from './pt';
14 | import { RomanianLanguage } from './ro';
15 | import { SwedishLanguage } from './sv';
16 | import { TurkishLanguage } from './tr';
17 |
18 | const languages = {
19 | en: EnglishLanguage,
20 | ar: ArabicLanguage,
21 | nl: DutchLanguage,
22 | fr: FrenchLanguage,
23 | de: GermanLanguage,
24 | pt: PortugueseLanguage,
25 | es: SpanishLanguage,
26 | tr: TurkishLanguage,
27 | pl: PolishLanguage,
28 | fi: FinnishLanguage,
29 | it: ItalianLanguage,
30 | no: NorwegianLanguage,
31 | ro: RomanianLanguage,
32 | da: DanishLanguage,
33 | sv: SwedishLanguage,
34 | } as const;
35 |
36 | export const LANGUAGES = Object.keys(languages) as (keyof typeof languages)[];
37 | export type Language = (typeof LANGUAGES)[number];
38 |
39 | export function isSupportedLanguage(
40 | lang: string | null | undefined,
41 | ): lang is Language {
42 | return LANGUAGES.includes(lang as Language);
43 | }
44 |
45 | export function getTranslation(
46 | key: TranslationKeyU,
47 | lang: Language,
48 | overrides: WidgetConfig['translationOverrides'],
49 | ): string {
50 | return overrides?.[lang]?.[key] || languages[lang][key] || '';
51 | }
52 |
53 | export type TranslationInterface = {
54 | i_need_more_help: string;
55 | this_was_helpful: string;
56 | write_a_message_placeholder: string;
57 | your_issue_has_been_resolved: string;
58 | new_conversation: string;
59 | back_to_conversations: string;
60 | closed_conversations: string;
61 | no_conversations_yet: string;
62 | welcome_screen_title: string;
63 | welcome_screen_description: string;
64 | your_name_placeholder: string;
65 | your_email_placeholder: string;
66 | optional: string;
67 | start_chat_button: string;
68 | start_chat_button_loading: string;
69 | };
70 | export type TranslationKeyU = keyof TranslationInterface;
71 |
--------------------------------------------------------------------------------
/packages/core/src/translation/it.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const ItalianLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Scrivi un messaggio...',
5 | your_issue_has_been_resolved: 'Il tuo problema è stato risolto!',
6 | new_conversation: 'Nuova conversazione',
7 | welcome_screen_title: 'Benvenuto nella nostra chat di supporto',
8 | welcome_screen_description:
9 | 'Siamo qui per aiutarti! Inizia una conversazione e ti risponderemo il prima possibile.',
10 | your_name_placeholder: 'Il tuo nome',
11 | your_email_placeholder: 'Il tuo indirizzo email',
12 | start_chat_button: 'Parla con il supporto',
13 | start_chat_button_loading: 'Connessione in corso...',
14 | i_need_more_help: 'Ho bisogno di ulteriore aiuto',
15 | this_was_helpful: 'Questo è stato utile',
16 | optional: 'Opzionale',
17 | no_conversations_yet: 'Nessuna conversazione ancora',
18 | back_to_conversations: 'Torna alle conversazioni',
19 | closed_conversations: 'Conversazioni chiuse',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/nl.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const DutchLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Schrijf een bericht...',
5 | your_issue_has_been_resolved: 'Uw probleem is opgelost!',
6 | new_conversation: 'Nieuw gesprek',
7 | welcome_screen_title: 'Welkom bij onze supportchat',
8 | welcome_screen_description:
9 | 'We zijn hier om te helpen! Begin een gesprek en we nemen zo snel mogelijk contact met u op.',
10 | your_name_placeholder: 'Uw naam',
11 | your_email_placeholder: 'Uw e-mailadres',
12 | start_chat_button: 'Praat met ondersteuning',
13 | start_chat_button_loading: 'Verbinding maken...',
14 | i_need_more_help: 'Ik heb nog meer hulp nodig',
15 | this_was_helpful: 'Mijn vraag is opgelost',
16 | optional: 'Optioneel',
17 | no_conversations_yet: 'Nog geen gesprekken',
18 | back_to_conversations: 'Terug naar gesprekken',
19 | closed_conversations: 'Afgesloten gesprekken',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/no.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const NorwegianLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Skriv en melding...',
5 | your_issue_has_been_resolved: 'Problemet ditt er løst!',
6 | new_conversation: 'Ny samtale',
7 | welcome_screen_title: 'Velkommen til vår kundestøtte-chat',
8 | welcome_screen_description:
9 | 'Vi er her for å hjelpe! Start en samtale så kommer vi tilbake til deg så snart som mulig.',
10 | your_name_placeholder: 'Ditt navn',
11 | your_email_placeholder: 'Din e-postadresse',
12 | start_chat_button: 'Snakk med kundestøtte',
13 | start_chat_button_loading: 'Kobler til...',
14 | i_need_more_help: 'Jeg trenger mer hjelp',
15 | this_was_helpful: 'Dette var nyttig',
16 | optional: 'Valgfritt',
17 | no_conversations_yet: 'Ingen samtaler ennå',
18 | back_to_conversations: 'Tilbake til samtaler',
19 | closed_conversations: 'Lukkede samtaler',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/pl.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const PolishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Napisz wiadomość...',
5 | your_issue_has_been_resolved: 'Twój problem został rozwiązany!',
6 | new_conversation: 'Nowa rozmowa',
7 | welcome_screen_title: 'Witamy w naszym czacie wsparcia',
8 | welcome_screen_description:
9 | 'Jesteśmy tutaj, aby pomóc! Rozpocznij rozmowę, a odpowiemy jak najszybciej.',
10 | your_name_placeholder: 'Twoje imię',
11 | your_email_placeholder: 'Twój adres email',
12 | start_chat_button: 'Porozmawiaj ze wsparciem',
13 | start_chat_button_loading: 'Łączenie...',
14 | i_need_more_help: 'Potrzebuję więcej pomocy',
15 | this_was_helpful: 'To było pomocne',
16 | optional: 'Opcjonalne',
17 | no_conversations_yet: 'Jeszcze brak rozmów',
18 | back_to_conversations: 'Powrót do rozmów',
19 | closed_conversations: 'Zamknięte rozmowy',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/pt.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const PortugueseLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Escreva uma mensagem...',
5 | your_issue_has_been_resolved: 'Seu problema foi resolvido!',
6 | new_conversation: 'Nova conversa',
7 | welcome_screen_title: 'Bem-vindo ao nosso chat de suporte',
8 | welcome_screen_description:
9 | 'Estamos aqui para ajudar! Inicie uma conversa e responderemos o mais rápido possível.',
10 | your_name_placeholder: 'Seu nome',
11 | your_email_placeholder: 'Seu endereço de email',
12 | start_chat_button: 'Falar com o suporte',
13 | start_chat_button_loading: 'Conectando...',
14 | i_need_more_help: 'preciso de mais ajuda',
15 | this_was_helpful: 'Isso foi útil',
16 | optional: 'Opcional',
17 | no_conversations_yet: 'Nenhuma conversa ainda',
18 | back_to_conversations: 'Voltar para conversas',
19 | closed_conversations: 'Conversas fechadas',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/ro.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const RomanianLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Scrie un mesaj...',
5 | your_issue_has_been_resolved: 'Problema ta a fost rezolvată!',
6 | new_conversation: 'Conversație nouă',
7 | welcome_screen_title: 'Bine ai venit la chat-ul nostru de suport',
8 | welcome_screen_description:
9 | 'Suntem aici să te ajutăm! Începe o conversație și îți vom răspunde cât mai curând posibil.',
10 | your_name_placeholder: 'Numele tău',
11 | your_email_placeholder: 'Adresa ta de email',
12 | start_chat_button: 'Vorbește cu suportul',
13 | start_chat_button_loading: 'Se conectează...',
14 | i_need_more_help: 'Am nevoie de mai mult ajutor',
15 | this_was_helpful: 'Acest lucru a fost util',
16 | optional: 'Opțional',
17 | no_conversations_yet: 'Încă nu există conversații',
18 | back_to_conversations: 'Înapoi la conversații',
19 | closed_conversations: 'Conversații închise',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/sv.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const SwedishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Skriv ett meddelande...',
5 | your_issue_has_been_resolved: 'Ditt problem har lösts!',
6 | new_conversation: 'Ny konversation',
7 | welcome_screen_title: 'Välkommen till vår supportchatt',
8 | welcome_screen_description:
9 | 'Vi är här för att hjälpa! Starta en konversation så återkommer vi till dig så snart som möjligt.',
10 | your_name_placeholder: 'Ditt namn',
11 | your_email_placeholder: 'Din e-postadress',
12 | start_chat_button: 'Prata med support',
13 | start_chat_button_loading: 'Ansluter...',
14 | i_need_more_help: 'Jag behöver mer hjälp',
15 | this_was_helpful: 'Detta var användbart',
16 | optional: 'Frivilligt',
17 | no_conversations_yet: 'Inga konversationer ännu',
18 | back_to_conversations: 'Tillbaka till konversationer',
19 | closed_conversations: 'Stängda konversationer',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/translation/tr.ts:
--------------------------------------------------------------------------------
1 | import type { TranslationInterface } from '.';
2 |
3 | export const TurkishLanguage: TranslationInterface = {
4 | write_a_message_placeholder: 'Bir mesaj yazın...',
5 | your_issue_has_been_resolved: 'Sorununuz çözüldü!',
6 | new_conversation: 'Yeni konuşma',
7 | welcome_screen_title: 'Destek sohbetimize hoş geldiniz',
8 | welcome_screen_description:
9 | 'Yardım etmek için buradayız! Bir konuşma başlatın, en kısa sürede size geri döneceğiz.',
10 | your_name_placeholder: 'Adınız',
11 | your_email_placeholder: 'E-posta adresiniz',
12 | start_chat_button: 'Destekle konuş',
13 | start_chat_button_loading: 'Bağlanıyor...',
14 | i_need_more_help: 'Daha fazla yardıma ihtiyacım var',
15 | this_was_helpful: 'Bu yardımcı oldu',
16 | optional: 'İsteğe bağlı',
17 | no_conversations_yet: 'Henüz konuşma yok',
18 | back_to_conversations: 'Konuşmalara geri dön',
19 | closed_conversations: 'Kapatılan konuşmalar',
20 | };
21 |
--------------------------------------------------------------------------------
/packages/core/src/types/agent-or-bot.ts:
--------------------------------------------------------------------------------
1 | export type AgentOrBotType = {
2 | isAi: boolean;
3 | id: string | null;
4 | name: string;
5 | avatar: string | null;
6 | };
7 |
--------------------------------------------------------------------------------
/packages/core/src/types/component-name.ts:
--------------------------------------------------------------------------------
1 | export type OpenCxComponentNameU =
2 | /* ------------------------------------------------------ */
3 | /* UI Lib */
4 | /* ------------------------------------------------------ */
5 | | 'ui_lib/btn'
6 | /* ------------------------------------------------------ */
7 | /* Trigger */
8 | /* ------------------------------------------------------ */
9 | | 'trigger/btn'
10 |
11 | /* ------------------------------------------------------ */
12 | /* Sessions Screen */
13 | /* ------------------------------------------------------ */
14 | | 'sessions/root'
15 | | 'sessions/header'
16 | | 'sessions/new_conversation_btn'
17 |
18 | /* ------------------------------------------------------ */
19 | /* Chat Screen */
20 | /* ------------------------------------------------------ */
21 | | 'chat/root'
22 | | 'chat/header'
23 | | 'chat/main/root'
24 | | 'chat/canvas/root'
25 | | 'chat/msgs/root'
26 | /* -------------------- Agent Message ------------------- */
27 | | 'chat/agent_msg_group/root'
28 | | 'chat/agent_msg_group/avatar_and_msgs/root'
29 | | 'chat/agent_msg_group/avatar_and_msgs/avatar'
30 | | 'chat/agent_msg_group/avatar_and_msgs/msgs'
31 | | 'chat/agent_msg_group/root/avatar'
32 | | 'chat/agent_msg_group/suggestions'
33 | | 'chat/agent_msg/root'
34 | | 'chat/agent_msg/msg'
35 | /* -------------------- Chat Message -------------------- */
36 | | 'chat/user_msg_group/root'
37 | | 'chat/user_msg_group/avatar/root'
38 | | 'chat/user_msg/root'
39 | | 'chat/user_msg/msg'
40 | /* --------------------- Chat Input --------------------- */
41 | | 'chat/input_box/root'
42 | | 'chat/input_box/inner_root'
43 | | 'chat/input_box/textarea_and_attachments_container'
44 | | 'chat/input_box/textarea'
45 | | 'chat/input_box/attachments_container'
46 | /* --------------------- Chat Utils --------------------- */
47 | | 'chat/bot_loading/root'
48 | | 'chat/bot_loading/bouncing_dots_container'
49 | | 'chat/suggested_reply_btn'
50 | | 'chat/might_solve_user_issue_suggested_replies_container';
51 |
--------------------------------------------------------------------------------
/packages/core/src/types/dtos.ts:
--------------------------------------------------------------------------------
1 | import type { Dto } from '../api/client';
2 |
3 | export type VoteInputDto = Dto['WidgetVoteDto'];
4 | export type VoteOutputDto = Dto['WidgetVoteResponseDto'];
5 |
6 | export type SendMessageDto = Dto['WidgetSendMessageInputDto'];
7 | export type SendMessageOutputDto = Dto['WidgetSendMessageOutputDto'];
8 |
9 | export type ResolveSessionDto = Dto['WidgetResolveSessionInputDto'];
10 |
11 | export type SessionDto = Dto['WidgetSessionDto'];
12 | export type MessageDto = Dto['WidgetHistoryDto'];
13 | export type MessageAttachmentType = NonNullable<
14 | Dto['WidgetHistoryDto']['attachments']
15 | >[number];
16 |
17 | export type ActionCallDto = NonNullable<
18 | Dto['WidgetHistoryDto']['actionCalls']
19 | >[number];
20 |
21 | export type ModeDto = Dto['WidgetConfigDto']['modes'][number];
22 |
--------------------------------------------------------------------------------
/packages/core/src/types/external-storage.ts:
--------------------------------------------------------------------------------
1 | export type ExternalStorage = {
2 | get: (key: string) => Promise;
3 | set: (key: string, value: string) => Promise;
4 | remove: (key: string) => Promise;
5 | };
6 |
--------------------------------------------------------------------------------
/packages/core/src/types/helpers.ts:
--------------------------------------------------------------------------------
1 | export type SafeExtract = Extract;
2 | export type SafeOmit = Omit;
3 |
4 | export type StringOrLiteral = T | (string & {});
5 |
--------------------------------------------------------------------------------
/packages/core/src/types/icons.ts:
--------------------------------------------------------------------------------
1 | import { icons } from 'lucide';
2 | import type { SafeExtract } from './helpers';
3 |
4 | export type IconNameU = SafeExtract<
5 | keyof typeof icons,
6 | /* ------------------------- <-> ------------------------ */
7 | | 'Maximize'
8 | | 'Maximize2'
9 | | 'Minimize'
10 | | 'Minimize2'
11 | | 'Expand'
12 | | 'Shrink'
13 | /* -------------------------- X ------------------------- */
14 | | 'X'
15 | | 'SquareX'
16 | | 'CircleX'
17 | /* -------------------------- ✅ ------------------------- */
18 | | 'Check'
19 | | 'CheckCheck'
20 | | 'CircleCheck'
21 | | 'CircleCheckBig'
22 | | 'SquareCheck'
23 | | 'SquareCheckBig'
24 | >;
25 |
--------------------------------------------------------------------------------
/packages/core/src/types/json-value.ts:
--------------------------------------------------------------------------------
1 | type JsonArray = JsonValue[];
2 |
3 | type JsonObject = {
4 | [x: string]: JsonValue | undefined;
5 | };
6 |
7 | type JsonPrimitive = boolean | number | string | null;
8 |
9 | export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
10 |
--------------------------------------------------------------------------------
/packages/core/src/types/messages.ts:
--------------------------------------------------------------------------------
1 | import type { MessageAttachmentType, MessageDto } from './dtos';
2 | import type { SafeExtract, StringOrLiteral } from './helpers';
3 | import type { AgentOrBotType } from './agent-or-bot';
4 |
5 | /* ------------------------------------------------------ */
6 | /* Component-related types */
7 | /* ------------------------------------------------------ */
8 | export type LiteralWidgetComponentKey =
9 | | 'bot_message'
10 | | 'agent_message'
11 | | 'loading'
12 | | 'fallback';
13 | export type WidgetComponentKey = StringOrLiteral;
14 |
15 | /* ------------------------------------------------------ */
16 | /* Message types */
17 | /* ------------------------------------------------------ */
18 | export type WidgetUserMessage = {
19 | id: string;
20 | type: 'USER';
21 | content: string;
22 | deliveredAt: string | null;
23 | attachments?: MessageAttachmentType[] | null;
24 | timestamp: string | null;
25 | user?: {
26 | name?: string;
27 | email?: string;
28 | phone?: string;
29 | customData?: Record;
30 | avatarUrl?: string;
31 | };
32 | };
33 |
34 | export type WidgetAiMessage = {
35 | id: string;
36 | type: 'AI';
37 | /**
38 | * The type is a bot_message literal string or other strings that correspond to the UI responses from AI action calls
39 | */
40 | component: StringOrLiteral>;
41 | data: {
42 | message: string;
43 | variant?: 'default' | 'error';
44 | action?: {
45 | name: string;
46 | data: TActionData;
47 | } | null;
48 | };
49 | timestamp: string | null;
50 | agent?: AgentOrBotType;
51 | attachments?: MessageAttachmentType[];
52 | };
53 |
54 | export type WidgetAgentMessage = {
55 | id: string;
56 | type: 'AGENT';
57 | component: SafeExtract;
58 | data: {
59 | message: string;
60 | variant?: 'default' | 'error';
61 | action?: undefined;
62 | };
63 | timestamp: string | null;
64 | agent?: AgentOrBotType;
65 | attachments?: MessageAttachmentType[];
66 | };
67 |
68 | export type WidgetSystemMessage__StateCheckpoint = {
69 | id: string;
70 | type: 'SYSTEM';
71 | subtype: 'state_checkpoint';
72 | timestamp: string | null;
73 | attachments?: undefined;
74 | data: {
75 | payload: unknown;
76 | };
77 | };
78 | export type WidgetSystemMessage__CsatRequested = {
79 | id: string;
80 | type: 'SYSTEM';
81 | subtype: 'csat_requested';
82 | timestamp: string | null;
83 | attachments?: undefined;
84 | data: {
85 | payload?: undefined;
86 | };
87 | };
88 | export type WidgetSystemMessage__CsatSubmitted = {
89 | id: string;
90 | type: 'SYSTEM';
91 | subtype: 'csat_submitted';
92 | timestamp: string | null;
93 | attachments?: undefined;
94 | data: {
95 | payload: {
96 | score: number | null | undefined;
97 | feedback: string | null | undefined;
98 | };
99 | };
100 | };
101 | export type WidgetSystemMessageU =
102 | | WidgetSystemMessage__StateCheckpoint
103 | | WidgetSystemMessage__CsatRequested
104 | | WidgetSystemMessage__CsatSubmitted;
105 |
106 | /* ------------------------------------------------------ */
107 | /* Union */
108 | /* ------------------------------------------------------ */
109 | export type WidgetMessageU =
110 | | WidgetUserMessage
111 | | WidgetAiMessage
112 | | WidgetAgentMessage
113 | | WidgetSystemMessageU;
114 |
--------------------------------------------------------------------------------
/packages/core/src/utils/Poller.ts:
--------------------------------------------------------------------------------
1 | import { PrimitiveState } from './PrimitiveState';
2 |
3 | export type PollingState = {
4 | isPolling: boolean;
5 | isError: boolean;
6 | };
7 |
8 | export class Poller {
9 | state = new PrimitiveState({
10 | isPolling: false,
11 | isError: false,
12 | });
13 | private abortController = new AbortController();
14 |
15 | reset = () => {
16 | this.abortController.abort('Resetting poller');
17 | this.stopPolling?.();
18 | this.stopPolling = null;
19 | };
20 |
21 | private stopPolling: (() => void) | null = null;
22 |
23 | startPolling = (
24 | cb: (abortSignal: AbortSignal) => Promise,
25 | intervalMs: number,
26 | ) => {
27 | if (this.stopPolling) return;
28 |
29 | const timeouts: NodeJS.Timeout[] = [];
30 |
31 | const poll = async () => {
32 | this.abortController = new AbortController();
33 | this.state.setPartial({ isPolling: true });
34 |
35 | try {
36 | await cb(this.abortController.signal);
37 | } catch (error) {
38 | if (this.abortController.signal.aborted) {
39 | // If aborted, just return and do not schedule the nest poll
40 | return;
41 | }
42 | console.error('Failed to poll:', error);
43 | this.state.setPartial({ isError: true });
44 | } finally {
45 | this.state.setPartial({ isPolling: false });
46 | }
47 |
48 | // Another check to stop scheduling polls in case someone removes the early return in the catch above
49 | if (this.abortController.signal.aborted) {
50 | console.log('Poller aborted, not scheduling anymore');
51 | } else {
52 | timeouts.push(setTimeout(poll, intervalMs));
53 | }
54 | };
55 |
56 | poll();
57 |
58 | this.stopPolling = () => {
59 | timeouts.forEach(clearTimeout);
60 | this.state.reset();
61 | };
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/packages/core/src/utils/PrimitiveState.ts:
--------------------------------------------------------------------------------
1 | import isEqual from 'lodash.isequal';
2 |
3 | export type Subscriber = (data: T) => void;
4 |
5 | export class PrimitiveState {
6 | private subscribers = new Set>();
7 | private state: S;
8 | private initialState: S;
9 |
10 | constructor(state: S) {
11 | this.state = state;
12 | this.initialState = state;
13 | }
14 |
15 | get = (): S => this.state;
16 |
17 | set = (newState: S): void => {
18 | if (!isEqual(this.state, newState)) {
19 | this.state = newState;
20 | this.notifySubscribers(newState);
21 | }
22 | };
23 |
24 | setPartial = (_s: Partial): void => {
25 | if (_s === undefined || _s === null) return;
26 | const newState = { ...this.state, ..._s };
27 | this.set(newState);
28 | };
29 |
30 | reset = (): void => {
31 | this.set(this.initialState);
32 | };
33 |
34 | private notifySubscribers = (state: S) => {
35 | const subscribersArray = Array.from(this.subscribers);
36 | subscribersArray.forEach((callback) => {
37 | try {
38 | callback(state);
39 | } catch (error) {
40 | if (import.meta.env.MODE !== 'test') {
41 | console.error(error);
42 | }
43 | }
44 | });
45 | };
46 |
47 | subscribe = (callback: Subscriber): (() => void) => {
48 | this.subscribers.add(callback);
49 |
50 | return () => {
51 | this.subscribers.delete(callback);
52 | };
53 | };
54 | }
55 |
--------------------------------------------------------------------------------
/packages/core/src/utils/is-exhaustive.ts:
--------------------------------------------------------------------------------
1 | export function isExhaustive(value: never, funcName: string) {
2 | console.error(`Missing case for ${value} in ${funcName}`);
3 | }
4 |
--------------------------------------------------------------------------------
/packages/core/src/utils/run-catching.ts:
--------------------------------------------------------------------------------
1 | export function run(fn: () => T): T {
2 | return fn();
3 | }
4 |
5 | export type Result =
6 | | { data: T; error?: undefined }
7 | | { data?: undefined; error: E };
8 |
9 | /**
10 | * Many thanks to @m-tabaza for this utility... Kotlin vibes
11 | */
12 | export function runCatching(
13 | callback: () => Promise,
14 | ): Promise>;
15 | export function runCatching(callback: () => T): Result;
16 | export function runCatching(
17 | callback: () => T | Promise,
18 | ): Result | Promise> {
19 | try {
20 | const result = callback();
21 |
22 | if (result instanceof Promise) {
23 | return result.then((data) => ({ data })).catch((error: E) => ({ error }));
24 | }
25 |
26 | return { data: result };
27 | } catch (error) {
28 | return { error: error as E };
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/core/src/utils/uuid.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from 'uuid';
2 |
3 | export function genUuid() {
4 | return uuidv4();
5 | }
6 |
--------------------------------------------------------------------------------
/packages/core/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@opencx/tsconfig/tsconfig.base.json",
3 | "exclude": ["node_modules", "dist"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/core/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path';
2 | import { defineConfig } from 'vite';
3 | import dts from 'vite-plugin-dts';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 | import { externalizeDeps } from 'vite-plugin-externalize-deps';
6 | import { name } from './package.json';
7 |
8 | export default defineConfig({
9 | plugins: [
10 | tsconfigPaths(),
11 | dts({
12 | insertTypesEntry: true,
13 | include: ['src'],
14 | }),
15 | externalizeDeps(),
16 | ],
17 | build: {
18 | outDir: 'dist',
19 | lib: {
20 | name,
21 | formats: ['cjs', 'es'],
22 | entry: {
23 | index: resolve(__dirname, 'src/index.ts'),
24 | },
25 | },
26 | sourcemap: true,
27 | },
28 | clearScreen: false,
29 | });
30 |
--------------------------------------------------------------------------------
/packages/core/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import tsconfigPaths from 'vite-tsconfig-paths';
2 | import { defineConfig } from 'vitest/config';
3 |
4 | export default defineConfig({
5 | plugins: [tsconfigPaths()],
6 | test: {
7 | typecheck: {
8 | enabled: true,
9 | },
10 | printConsoleTrace: true,
11 | globals: true,
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/packages/embed/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @opencx/widget
2 |
3 | ## 4.0.30
4 |
5 | ### Patch Changes
6 |
7 | - add `WidgetConfig.specialComponents.headerBottom`
8 |
9 | ## 4.0.29
10 |
11 | ### Patch Changes
12 |
13 | - fix avatar url for bot persistable initial messages
14 |
15 | ## 4.0.28
16 |
17 | ### Patch Changes
18 |
19 | - handle unexpected AI errors more gracefully
20 |
21 | ## 4.0.27
22 |
23 | ### Patch Changes
24 |
25 | - fix loading state when awaiting bot reply
26 |
27 | ## 4.0.26
28 |
29 | ### Patch Changes
30 |
31 | - enhance parsing sessionCustomData
32 |
33 | ## 4.0.25
34 |
35 | ### Patch Changes
36 |
37 | - add translations for Danish, Finnish, Italian, Norwegian, Romanian and Swedish
38 |
39 | ## 4.0.24
40 |
41 | ### Patch Changes
42 |
43 | - make @opencx/widget dep free
44 | - eb2b209: cleanup @opencx/widget deps
45 |
46 | ## 4.0.24-prerelease.0
47 |
48 | ### Patch Changes
49 |
50 | - cleanup @opencx/widget deps
51 |
52 | ## 4.0.23
53 |
54 | ### Patch Changes
55 |
56 | - Add polish translations
57 | - Updated dependencies
58 | - @opencx/widget-core@4.0.23
59 | - @opencx/widget-react@4.0.23
60 |
61 | ## 4.0.22
62 |
63 | ### Patch Changes
64 |
65 | - remove incompatible regex with safari<16
66 | - Updated dependencies
67 | - @opencx/widget-core@4.0.22
68 | - @opencx/widget-react@4.0.22
69 |
70 | ## 4.0.21
71 |
72 | ### Patch Changes
73 |
74 | - do not hardcode non verified name as anonymous
75 | - Updated dependencies
76 | - @opencx/widget-core@4.0.21
77 | - @opencx/widget-react@4.0.21
78 |
79 | ## 4.0.20
80 |
81 | ### Patch Changes
82 |
83 | - add chatBottomComponents
84 | - Updated dependencies
85 | - @opencx/widget-react@4.0.20
86 | - @opencx/widget-core@4.0.20
87 |
88 | ## 4.0.19
89 |
90 | ### Patch Changes
91 |
92 | - add WidgetConfig.chatFooterItems
93 | - Updated dependencies
94 | - @opencx/widget-react@4.0.19
95 | - @opencx/widget-core@4.0.19
96 |
97 | ## 4.0.18
98 |
99 | ### Patch Changes
100 |
101 | - overridable translations
102 | - Updated dependencies
103 | - @opencx/widget-react@4.0.18
104 | - @opencx/widget-core@4.0.18
105 |
106 | ## 4.0.17
107 |
108 | ### Patch Changes
109 |
110 | - publish embed under `@opencx/widget` just like it was before
111 | - Updated dependencies
112 | - @opencx/widget-react@4.0.17
113 | - @opencx/widget-core@4.0.17
114 |
--------------------------------------------------------------------------------
/packages/embed/README.md:
--------------------------------------------------------------------------------
1 | # OpenCX Widget - Embed
2 |
3 | The default React widget. Embeddable in HTML.
4 |
5 | For more information, check [the documentation](https://docs.open.cx/widget/getting-started)
6 |
--------------------------------------------------------------------------------
/packages/embed/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | OpenCX Widget
8 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/packages/embed/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/widget",
3 | "private": false,
4 | "version": "4.0.30",
5 | "type": "module",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/openchatai/widget"
9 | },
10 | "scripts": {
11 | "clean": "rm -rf node_modules dist-embed .turbo",
12 | "clean:dist": "rm -rf dist-embed .turbo",
13 | "build": "vite build -c vite.config.ts",
14 | "test": "vitest run",
15 | "type-check": "tsc --noEmit",
16 | "lint": "eslint .",
17 | "format": "prettier --write ."
18 | },
19 | "files": [
20 | "dist-embed"
21 | ],
22 | "devDependencies": {
23 | "@opencx/eslint-config": "workspace:*",
24 | "@opencx/tsconfig": "workspace:*",
25 | "@opencx/widget-core": "workspace:*",
26 | "@opencx/widget-react": "workspace:*",
27 | "@types/react": ">=18 <20",
28 | "@types/react-dom": ">=18 <20",
29 | "react": ">=18 <20",
30 | "react-dom": ">=18 <20"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/embed/src/index.tsx:
--------------------------------------------------------------------------------
1 | import type { WidgetConfig } from '@opencx/widget-core';
2 | import { Widget } from '@opencx/widget-react';
3 | import React from 'react';
4 | import { createRoot } from 'react-dom/client';
5 | import { version } from '../package.json';
6 |
7 | const defaultRootId = 'opencx-root';
8 |
9 | declare global {
10 | interface Window {
11 | initOpenScript: typeof initOpenScript;
12 | openCXWidgetVersion: string;
13 | }
14 | }
15 |
16 | function initOpenScript(options: WidgetConfig) {
17 | render(defaultRootId, );
18 | }
19 |
20 | window.initOpenScript = initOpenScript;
21 | window.openCXWidgetVersion = version;
22 |
23 | export function render(rootId: string, component: React.JSX.Element) {
24 | let rootElement = document.getElementById(rootId);
25 | if (!rootElement) {
26 | rootElement = document.createElement('div');
27 | rootElement.id = rootId;
28 | document.body.appendChild(rootElement);
29 | }
30 |
31 | return createRoot(rootElement).render(component);
32 | }
33 |
--------------------------------------------------------------------------------
/packages/embed/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@opencx/tsconfig/tsconfig.base.json",
3 | "exclude": ["node_modules", "dist", "dist-embed"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/embed/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/embed/vite.config.ts:
--------------------------------------------------------------------------------
1 | import reactPlugin from '@vitejs/plugin-react-swc';
2 | import { defineConfig } from 'vite';
3 | import tsconfigPaths from 'vite-tsconfig-paths';
4 |
5 | export default defineConfig({
6 | plugins: [reactPlugin(), tsconfigPaths()],
7 | build: {
8 | assetsInlineLimit: 10 * 1024,
9 | emptyOutDir: true,
10 | sourcemap: true,
11 | rollupOptions: {
12 | input: 'src/index.tsx',
13 | output: {
14 | format: 'iife', // Immediately-Invoked Function Expression
15 | dir: 'dist-embed',
16 | entryFileNames: 'script.js',
17 | extend: true,
18 | },
19 | },
20 | },
21 | server: {
22 | port: 3005,
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/packages/embed/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import tsconfigPaths from 'vite-tsconfig-paths';
2 | import { defineConfig } from 'vitest/config';
3 | import react from '@vitejs/plugin-react-swc';
4 |
5 | export default defineConfig({
6 | plugins: [tsconfigPaths(), react()],
7 | test: {
8 | typecheck: {
9 | enabled: true,
10 | },
11 | printConsoleTrace: true,
12 | environment: 'jsdom',
13 | globals: true,
14 | passWithNoTests: true,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/packages/eslint-config/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @opencx/eslint-config
2 |
3 | ## 0.0.1
4 |
5 | ### Patch Changes
6 |
7 | - fix avatar url for bot persistable initial messages
8 |
--------------------------------------------------------------------------------
/packages/eslint-config/base.js:
--------------------------------------------------------------------------------
1 | import { defineConfig, globalIgnores } from 'eslint/config';
2 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin';
3 | import unusedImportsPlugin from 'eslint-plugin-unused-imports';
4 | import vitestPlugin from 'eslint-plugin-vitest';
5 | import globals from 'globals';
6 | import tsParser from '@typescript-eslint/parser';
7 | import path from 'node:path';
8 | import { fileURLToPath } from 'node:url';
9 | import js from '@eslint/js';
10 | import { FlatCompat } from '@eslint/eslintrc';
11 | import reactPlugin from 'eslint-plugin-react';
12 | import reactHooksPlugin from 'eslint-plugin-react-hooks';
13 | import prettierPlugin from 'eslint-plugin-prettier';
14 |
15 | const __filename = fileURLToPath(import.meta.url);
16 | const __dirname = path.dirname(__filename);
17 | const compat = new FlatCompat({
18 | baseDirectory: __dirname,
19 | recommendedConfig: js.configs.recommended,
20 | allConfig: js.configs.all,
21 | });
22 |
23 | export default defineConfig([
24 | globalIgnores(['**/dist', '**/node_modules']),
25 | {
26 | plugins: {
27 | '@typescript-eslint': typescriptEslintEslintPlugin,
28 | 'unused-imports': unusedImportsPlugin,
29 | vitest: vitestPlugin,
30 | react: reactPlugin,
31 | 'react-hooks': reactHooksPlugin,
32 | prettier: prettierPlugin,
33 | },
34 |
35 | extends: compat.extends(
36 | 'plugin:@typescript-eslint/recommended',
37 | 'plugin:react/recommended',
38 | 'plugin:react-hooks/recommended',
39 | 'plugin:prettier/recommended',
40 | 'prettier',
41 | ),
42 |
43 | languageOptions: {
44 | globals: {
45 | ...globals.node,
46 | ...globals.vitest,
47 | },
48 |
49 | parser: tsParser,
50 | ecmaVersion: 'latest',
51 | sourceType: 'module',
52 |
53 | parserOptions: {
54 | project: 'tsconfig.json',
55 | tsconfigRootDir: __dirname,
56 | ecmaFeatures: {
57 | jsx: true,
58 | },
59 | },
60 | },
61 |
62 | settings: {
63 | react: {
64 | version: 'detect',
65 | },
66 | },
67 |
68 | rules: {
69 | '@typescript-eslint/no-unused-expressions': 'off',
70 |
71 | 'prettier/prettier': 'error',
72 | 'arrow-body-style': 'off',
73 | 'prefer-arrow-callback': 'off',
74 |
75 | 'react/prop-types': 'off',
76 | 'react/jsx-uses-react': 'warn',
77 | 'react/jsx-uses-vars': 'warn',
78 |
79 | 'vitest/expect-expect': 'warn',
80 | 'vitest/no-disabled-tests': 'warn',
81 | 'vitest/no-focused-tests': 'error',
82 |
83 | 'unused-imports/no-unused-imports': 'error',
84 |
85 | '@typescript-eslint/no-unused-vars': 'off',
86 | 'unused-imports/no-unused-vars': [
87 | 'warn',
88 | {
89 | vars: 'all',
90 | varsIgnorePattern: '^_',
91 | args: 'after-used',
92 | argsIgnorePattern: '^_',
93 | },
94 | ],
95 | },
96 | },
97 | ]);
98 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/eslint-config",
3 | "version": "0.0.1",
4 | "type": "module",
5 | "private": true,
6 | "exports": {
7 | "./base": "./base.js"
8 | },
9 | "scripts": {
10 | "clean": "rm -rf node_modules .turbo"
11 | },
12 | "devDependencies": {
13 | "globals": "^16.0.0",
14 | "@eslint/eslintrc": "^3.3.1",
15 | "@eslint/js": "^9.23.0",
16 | "@typescript-eslint/eslint-plugin": "^8.29.0",
17 | "@typescript-eslint/parser": "^8.29.0",
18 | "eslint": "^9.23.0",
19 | "eslint-config-prettier": "^10.1.1",
20 | "eslint-plugin-prettier": "^5.2.6",
21 | "eslint-plugin-react": "^7.37.5",
22 | "eslint-plugin-react-hooks": "^5.2.0",
23 | "eslint-plugin-unused-imports": "^4.1.4",
24 | "eslint-plugin-vitest": "^0.5.4"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/react-headless/README.md:
--------------------------------------------------------------------------------
1 | # OpenCX Widget - React Headless
2 |
3 | Headless React helpers and hooks.
4 |
5 | For more information, check [the documentation](https://docs.open.cx/widget/getting-started)
6 |
--------------------------------------------------------------------------------
/packages/react-headless/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/widget-react-headless",
3 | "private": false,
4 | "version": "4.0.30",
5 | "type": "module",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/openchatai/widget"
9 | },
10 | "scripts": {
11 | "clean": "rm -rf node_modules dist .turbo",
12 | "clean:dist": "rm -rf dist .turbo",
13 | "build": "vite build",
14 | "test": "vitest run",
15 | "type-check": "tsc --noEmit",
16 | "lint": "eslint .",
17 | "format": "prettier --write ."
18 | },
19 | "files": [
20 | "dist"
21 | ],
22 | "exports": {
23 | ".": {
24 | "types": "./dist/index.d.ts",
25 | "import": "./dist/index.js",
26 | "require": "./dist/index.cjs"
27 | }
28 | },
29 | "dependencies": {
30 | "@opencx/widget-core": "workspace:*",
31 | "swr": "^2.2.5",
32 | "uuid": "^11.0.4"
33 | },
34 | "peerDependencies": {
35 | "@types/react": ">=18 <20",
36 | "@types/react-dom": ">=18 <20",
37 | "react": ">=18 <20",
38 | "react-dom": ">=18 <20"
39 | },
40 | "devDependencies": {
41 | "@opencx/eslint-config": "workspace:*",
42 | "@opencx/tsconfig": "workspace:*"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/react-headless/src/ComponentRegistry.ts:
--------------------------------------------------------------------------------
1 | import type { WidgetComponentKey } from '@opencx/widget-core';
2 | import type { WidgetComponentType } from './types/components';
3 |
4 | export class ComponentRegistry {
5 | components: WidgetComponentType[] = [];
6 |
7 | constructor(opts: { components?: WidgetComponentType[] }) {
8 | const { components } = opts;
9 |
10 | if (components) {
11 | components.forEach((c) => this.register(c));
12 | }
13 |
14 | if (this.components.length === 0) {
15 | throw new Error('No components registered');
16 | }
17 | if (!this.get('fallback')) {
18 | throw new Error('No fallback component registered');
19 | }
20 | }
21 |
22 | // TODO test that this registers or replaces the component
23 | register(component: WidgetComponentType) {
24 | // Replace the key if it already exists
25 | const index = this.components.findIndex((c) => c.key === component.key);
26 | if (index !== -1) {
27 | this.components[index] = component;
28 | } else {
29 | this.components.push(component);
30 | }
31 | return this;
32 | }
33 |
34 | private get(key: WidgetComponentKey) {
35 | const c = this.components.find(
36 | (c) => c.key.toUpperCase() === key.toUpperCase(),
37 | );
38 | if (c) return c;
39 | return null;
40 | }
41 |
42 | public getComponent(key: string) {
43 | return this.get(key)?.component;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/react-headless/src/WidgetProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useContext,
4 | useEffect,
5 | useMemo,
6 | useRef,
7 | useState,
8 | } from 'react';
9 | import { version } from '../package.json';
10 | import {
11 | type ExternalStorage,
12 | type WidgetConfig,
13 | WidgetCtx,
14 | } from '@opencx/widget-core';
15 | import { ComponentRegistry } from './ComponentRegistry';
16 | import type { WidgetComponentType } from './types/components';
17 |
18 | interface WidgetProviderValue {
19 | widgetCtx: WidgetCtx;
20 | config: WidgetConfig;
21 | components?: WidgetComponentType[];
22 | componentStore: ComponentRegistry;
23 | version: string;
24 | contentIframeRef?: React.MutableRefObject;
25 | }
26 |
27 | const context = createContext(null);
28 |
29 | export function WidgetProvider({
30 | options: config,
31 | children,
32 | components,
33 | storage,
34 | loadingComponent,
35 | }: {
36 | options: WidgetConfig;
37 | children: React.ReactNode;
38 | components?: WidgetComponentType[];
39 | storage?: ExternalStorage;
40 | /**
41 | * Custom loading component while the widget is initializing
42 | * Not to be confused with the `loading` custom component which renders when the bot's reply is pending
43 | */
44 | loadingComponent?: React.ReactNode;
45 | }) {
46 | const contentIframeRef = useRef(null);
47 |
48 | const didInitialize = useRef(false);
49 | const [widgetCtx, setWidgetCtx] = useState(null);
50 |
51 | const componentStore = useMemo(
52 | () =>
53 | new ComponentRegistry({
54 | components: components,
55 | }),
56 | [components],
57 | );
58 |
59 | useEffect(() => {
60 | if (didInitialize.current) return;
61 | didInitialize.current = true;
62 |
63 | WidgetCtx.initialize({ config, storage })
64 | .then(setWidgetCtx)
65 | .catch(console.error);
66 | // eslint-disable-next-line react-hooks/exhaustive-deps
67 | }, []);
68 |
69 | if (!widgetCtx) {
70 | return loadingComponent || null;
71 | }
72 |
73 | return (
74 |
84 | {children}
85 |
86 | );
87 | }
88 |
89 | export function useWidget() {
90 | const ctx = useContext(context);
91 | if (!ctx) {
92 | throw new Error('useWidget must be used within a WidgetProvider');
93 | }
94 | return ctx;
95 | }
96 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useConfig.ts:
--------------------------------------------------------------------------------
1 | import { useWidget } from '../WidgetProvider';
2 |
3 | export function useConfig() {
4 | const { config } = useWidget();
5 |
6 | return config;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useContact.ts:
--------------------------------------------------------------------------------
1 | import { useWidget } from '../WidgetProvider';
2 | import { usePrimitiveState } from './usePrimitiveState';
3 |
4 | export function useContact() {
5 | const { widgetCtx } = useWidget();
6 | const contactState = usePrimitiveState(widgetCtx.contactCtx.state);
7 |
8 | return {
9 | contactState,
10 | createUnverifiedContact: widgetCtx.contactCtx.createUnverifiedContact,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useCsat.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | WidgetSystemMessage__CsatRequested,
3 | WidgetSystemMessage__CsatSubmitted,
4 | } from '@opencx/widget-core';
5 | import { useMemo } from 'react';
6 | import { useMessages } from './useMessages';
7 | import { useWidget } from '../WidgetProvider';
8 |
9 | export function useCsat() {
10 | const { widgetCtx } = useWidget();
11 | const {
12 | messagesState: { messages },
13 | } = useMessages();
14 |
15 | const {
16 | csatRequestedMessage,
17 | isCsatRequested,
18 | csatSubmittedMessage,
19 | isCsatSubmitted,
20 | submittedScore,
21 | submittedFeedback,
22 | } = useMemo(() => {
23 | const csatRequestedMessage = messages.find(
24 | (message): message is WidgetSystemMessage__CsatRequested =>
25 | message.type === 'SYSTEM' && message.subtype === 'csat_requested',
26 | );
27 | const csatSubmittedMessage = messages.findLast(
28 | (message): message is WidgetSystemMessage__CsatSubmitted =>
29 | message.type === 'SYSTEM' && message.subtype === 'csat_submitted',
30 | );
31 |
32 | return {
33 | csatRequestedMessage,
34 | isCsatRequested: !!csatRequestedMessage && !csatSubmittedMessage,
35 | csatSubmittedMessage,
36 | isCsatSubmitted: !!csatSubmittedMessage,
37 | submittedScore: csatSubmittedMessage?.data.payload.score,
38 | submittedFeedback: csatSubmittedMessage?.data.payload.feedback,
39 | };
40 | }, [messages]);
41 |
42 | return {
43 | submitCsat: widgetCtx.csatCtx.submitCsat,
44 | csatRequestedMessage,
45 | isCsatRequested,
46 | csatSubmittedMessage,
47 | isCsatSubmitted,
48 | submittedScore,
49 | submittedFeedback,
50 | };
51 | }
52 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useDocumentDir.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import type { StringOrLiteral } from '@opencx/widget-core';
3 |
4 | export function useDocumentDir() {
5 | const [dir, setDir] = useState>('ltr');
6 |
7 | useEffect(() => {
8 | const updateDir = () => {
9 | if (typeof document === 'undefined') return;
10 | setDir(
11 | window.getComputedStyle((window.top || window).document.body).direction,
12 | );
13 | };
14 |
15 | // Set initial direction
16 | updateDir();
17 |
18 | // Watch for direction changes on both document and documentElement
19 | const observer = new MutationObserver(updateDir);
20 |
21 | // Observe both document and documentElement
22 | observer.observe(document.documentElement, {
23 | attributes: true,
24 | attributeFilter: ['dir'],
25 | });
26 |
27 | observer.observe(document.body, {
28 | attributes: true,
29 | attributeFilter: ['dir'],
30 | });
31 |
32 | // Add event listener for dynamic changes
33 | window.addEventListener('languagechange', updateDir);
34 |
35 | return () => {
36 | observer.disconnect();
37 | window.removeEventListener('languagechange', updateDir);
38 | };
39 | }, []);
40 |
41 | return { dir };
42 | }
43 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useIsAwaitingBotReply.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useMessages } from './useMessages';
3 | import { useSessions } from './useSessions';
4 |
5 | export function useIsAwaitingBotReply() {
6 | const { sessionState } = useSessions();
7 | const { messagesState } = useMessages();
8 |
9 | const isSessionAssignedToAI = sessionState.session?.assignee.kind === 'ai';
10 | // This check is useful in cases where the user might navigate in and out of a chat, and `isSendingMessage` is reset back to its default value
11 | const isLastMessageAUserMessage =
12 | messagesState.messages?.at(-1)?.type === 'USER';
13 |
14 | const isAwaitingBotReply = (() => {
15 | if (messagesState.isSendingMessageToAI) return true;
16 | if (
17 | (isSessionAssignedToAI || sessionState.isCreatingSession) &&
18 | (messagesState.isSendingMessage || isLastMessageAUserMessage)
19 | ) {
20 | return true;
21 | }
22 | return false;
23 | })();
24 |
25 | return { isAwaitingBotReply };
26 | }
27 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useMessages.ts:
--------------------------------------------------------------------------------
1 | import { usePrimitiveState } from './usePrimitiveState';
2 | import { useWidget } from '../WidgetProvider';
3 |
4 | export function useMessages() {
5 | const { widgetCtx } = useWidget();
6 | const messagesState = usePrimitiveState(widgetCtx.messageCtx.state);
7 |
8 | return { messagesState, sendMessage: widgetCtx.messageCtx.sendMessage };
9 | }
10 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useModes.ts:
--------------------------------------------------------------------------------
1 | import { useWidget } from '../WidgetProvider';
2 | import { useConfig } from './useConfig';
3 | import { useSessions } from './useSessions';
4 |
5 | export function useModes() {
6 | const { widgetCtx } = useWidget();
7 | const { modesComponents } = useConfig();
8 | const { sessionState } = useSessions();
9 |
10 | const modes = widgetCtx.modes;
11 | const activeModeId = sessionState.session?.modeId;
12 | const activeMode = modes.find((mode) => mode.id === activeModeId);
13 |
14 | const Component = modesComponents?.find((modeComponent) =>
15 | [
16 | activeMode?.id || '',
17 | activeMode?.name?.toLowerCase() || '',
18 | activeMode?.slug?.toLowerCase() || '',
19 | ].includes(modeComponent.key.toLowerCase()),
20 | )?.component;
21 |
22 | return {
23 | modes,
24 | modesComponents,
25 | activeModeId,
26 | activeMode,
27 | Component,
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/usePrimitiveState.ts:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from 'react';
2 | import type { PrimitiveState } from '@opencx/widget-core';
3 |
4 | export function usePrimitiveState(p: PrimitiveState) {
5 | return useSyncExternalStore(p.subscribe, p.get, p.get);
6 | }
7 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useSessions.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useWidget } from '../WidgetProvider';
3 | import { usePrimitiveState } from './usePrimitiveState';
4 | import { useConfig } from './useConfig';
5 |
6 | export function useSessions() {
7 | const { widgetCtx } = useWidget();
8 | const { oneOpenSessionAllowed } = useConfig();
9 | const sessionState = usePrimitiveState(widgetCtx.sessionCtx.sessionState);
10 | const sessionsState = usePrimitiveState(widgetCtx.sessionCtx.sessionsState);
11 |
12 | const { openSessions, closedSessions } = useMemo(() => {
13 | return {
14 | openSessions: sessionsState.data.filter((s) => s.isOpened === true),
15 | closedSessions: sessionsState.data.filter((s) => s.isOpened === false),
16 | };
17 | }, [sessionsState.data]);
18 |
19 | const canCreateNewSession = useMemo(() => {
20 | if (oneOpenSessionAllowed) {
21 | return openSessions.length === 0;
22 | }
23 | return true;
24 | }, [oneOpenSessionAllowed, openSessions.length]);
25 |
26 | return {
27 | sessionState,
28 | sessionsState,
29 | loadMoreSessions: widgetCtx.sessionCtx.loadMoreSessions,
30 | resolveSession: widgetCtx.sessionCtx.resolveSession,
31 | createStateCheckpoint: widgetCtx.sessionCtx.createStateCheckpoint,
32 | openSessions,
33 | closedSessions,
34 | canCreateNewSession,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useUploadFiles.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState } from 'react';
2 | import { useWidget } from '../WidgetProvider';
3 | import { v4 } from 'uuid';
4 |
5 | const uploadAbortControllers: Map = new Map();
6 |
7 | interface FileWithProgress {
8 | status: 'pending' | 'uploading' | 'success' | 'error';
9 | id: string;
10 | file: File;
11 | fileUrl?: string;
12 | progress: number;
13 | error?: string;
14 | }
15 |
16 | function useUploadFiles() {
17 | const [files, setFiles] = useState([]);
18 | const {
19 | widgetCtx: { api },
20 | } = useWidget();
21 | function appendFiles(files: File[]) {
22 | const newFiles = files.map((file) => ({
23 | file,
24 | id: v4(),
25 | status: 'pending' as const,
26 | progress: 0,
27 | }));
28 |
29 | setFiles((prev) => [...prev, ...newFiles]);
30 | newFiles.forEach(uploadFile);
31 | }
32 |
33 | function updateFileById(id: string, update: Partial) {
34 | setFiles((prev) =>
35 | prev.map((f) => (f.id === id ? { ...f, ...update } : f)),
36 | );
37 | }
38 |
39 | function removeFileById(id: string) {
40 | setFiles((prev) => prev.filter((f) => f.id !== id));
41 | }
42 |
43 | const uploadFile = async (fileItem: FileWithProgress) => {
44 | const controller = new AbortController();
45 | uploadAbortControllers.set(fileItem.id, controller);
46 |
47 | try {
48 | setFiles((prev) =>
49 | prev.map((f) =>
50 | f.id === fileItem.id ? { ...f, status: 'uploading', progress: 0 } : f,
51 | ),
52 | );
53 |
54 | const response = await api.uploadFile({
55 | file: fileItem.file,
56 | abortSignal: controller.signal,
57 | onProgress: (percentage) => {
58 | updateFileById(fileItem.id, { progress: percentage });
59 | },
60 | });
61 |
62 | updateFileById(fileItem.id, {
63 | status: 'success',
64 | fileUrl: response.fileUrl,
65 | progress: 100,
66 | });
67 | } catch (error) {
68 | if (!controller.signal.aborted) {
69 | updateFileById(fileItem.id, {
70 | status: 'error',
71 | error: error instanceof Error ? error.message : 'Upload failed',
72 | progress: 0,
73 | });
74 | }
75 | } finally {
76 | uploadAbortControllers.delete(fileItem.id);
77 | }
78 | };
79 |
80 | const handleCancelUpload = (fileId: string) => {
81 | const controller = uploadAbortControllers.get(fileId);
82 | if (controller) {
83 | controller.abort();
84 | uploadAbortControllers.delete(fileId);
85 | }
86 | removeFileById(fileId);
87 | };
88 |
89 | const successFiles = useMemo(() => {
90 | return files.filter((f) => f.status === 'success' && f.fileUrl);
91 | }, [files]);
92 |
93 | function emptyTheFiles() {
94 | uploadAbortControllers.forEach((controller) => controller.abort());
95 | uploadAbortControllers.clear();
96 | setFiles([]);
97 | }
98 |
99 | useEffect(() => {
100 | return () => {
101 | uploadAbortControllers.forEach((controller) => controller.abort());
102 | uploadAbortControllers.clear();
103 | };
104 | }, []);
105 |
106 | return {
107 | allFiles: files,
108 | appendFiles,
109 | handleCancelUpload,
110 | successFiles,
111 | emptyTheFiles,
112 | getFileById: (id: string) => files.find((f) => f.id === id),
113 | getUploadProgress: (id: string) =>
114 | files.find((f) => f.id === id)?.progress ?? 0,
115 | getUploadStatus: (id: string) => files.find((f) => f.id === id)?.status,
116 | hasErrors: files.some((f) => f.status === 'error'),
117 | isUploading: files.some((f) => f.status === 'uploading'),
118 | };
119 | }
120 |
121 | export { useUploadFiles, type FileWithProgress };
122 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useWidgetRouter.ts:
--------------------------------------------------------------------------------
1 | import { useWidget } from '../WidgetProvider';
2 | import { usePrimitiveState } from './usePrimitiveState';
3 |
4 | export function useWidgetRouter() {
5 | const { widgetCtx } = useWidget();
6 |
7 | const routerState = usePrimitiveState(widgetCtx.routerCtx.state);
8 |
9 | return {
10 | routerState,
11 | toSessionsScreen: widgetCtx.routerCtx.toSessionsScreen,
12 | toChatScreen: widgetCtx.routerCtx.toChatScreen,
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/packages/react-headless/src/hooks/useWidgetTrigger.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useContext,
4 | useEffect,
5 | useState,
6 | type Dispatch,
7 | type ReactNode,
8 | type SetStateAction,
9 | } from 'react';
10 | import { useConfig } from './useConfig';
11 |
12 | type WidgetTriggerCtx = {
13 | isOpen: boolean;
14 | setIsOpen: Dispatch>;
15 | };
16 |
17 | const context = createContext(null);
18 |
19 | export function WidgetTriggerProvider({ children }: { children: ReactNode }) {
20 | const config = useConfig();
21 | const [isOpen, setIsOpen] = useState(() => {
22 | if (config.inline) return true;
23 | return config.isOpen ?? false;
24 | });
25 |
26 | useEffect(() => {
27 | setIsOpen((prev) => config.isOpen ?? prev);
28 | }, [config.isOpen]);
29 |
30 | useEffect(() => {
31 | const openAfterNSeconds = config.openAfterNSeconds;
32 | if (typeof openAfterNSeconds !== 'number' || isNaN(openAfterNSeconds))
33 | return;
34 |
35 | const timeout = setTimeout(() => setIsOpen(true), openAfterNSeconds * 1000);
36 |
37 | return () => clearTimeout(timeout);
38 | }, [config.openAfterNSeconds]);
39 |
40 | return (
41 |
42 | {children}
43 |
44 | );
45 | }
46 |
47 | export function useWidgetTrigger() {
48 | const ctx = useContext(context);
49 | if (!ctx) {
50 | throw new Error(
51 | 'useWidgetTrigger must be used within a WidgetTriggerProvider',
52 | );
53 | }
54 | return ctx;
55 | }
56 |
--------------------------------------------------------------------------------
/packages/react-headless/src/index.ts:
--------------------------------------------------------------------------------
1 | export type {
2 | WidgetComponentType,
3 | WidgetComponentProps,
4 | } from './types/components';
5 |
6 | export { WidgetProvider, useWidget } from './WidgetProvider';
7 |
8 | export { useConfig } from './hooks/useConfig';
9 | export { useContact } from './hooks/useContact';
10 | export { useDocumentDir } from './hooks/useDocumentDir';
11 | export { useIsAwaitingBotReply } from './hooks/useIsAwaitingBotReply';
12 | export { useMessages } from './hooks/useMessages';
13 | export { usePrimitiveState } from './hooks/usePrimitiveState';
14 | export { useSessions } from './hooks/useSessions';
15 | export { useWidgetRouter } from './hooks/useWidgetRouter';
16 | export { type FileWithProgress, useUploadFiles } from './hooks/useUploadFiles';
17 | export {
18 | useWidgetTrigger,
19 | WidgetTriggerProvider,
20 | } from './hooks/useWidgetTrigger';
21 | export { useModes } from './hooks/useModes';
22 | export { useCsat } from './hooks/useCsat';
--------------------------------------------------------------------------------
/packages/react-headless/src/types/components.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type {
3 | WidgetAgentMessage,
4 | WidgetAiMessage,
5 | WidgetComponentKey,
6 | WidgetSystemMessageU,
7 | } from '@opencx/widget-core';
8 |
9 | export type WidgetComponentProps =
10 | | WidgetAiMessage
11 | | WidgetAgentMessage
12 | | WidgetSystemMessageU;
13 |
14 | export type WidgetComponentType = {
15 | key: WidgetComponentKey;
16 | component: React.ElementType;
17 | };
18 |
--------------------------------------------------------------------------------
/packages/react-headless/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@opencx/tsconfig/tsconfig.base.json",
3 | "exclude": ["node_modules", "dist"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/react-headless/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/react-headless/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path';
2 | import { defineConfig } from 'vite';
3 | import dts from 'vite-plugin-dts';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 | import { externalizeDeps } from 'vite-plugin-externalize-deps';
6 | import { name } from './package.json';
7 |
8 | export default defineConfig({
9 | plugins: [
10 | tsconfigPaths(),
11 | dts({
12 | insertTypesEntry: true,
13 | include: ['src'],
14 | }),
15 | externalizeDeps(),
16 | ],
17 | build: {
18 | outDir: 'dist',
19 | lib: {
20 | name,
21 | formats: ['cjs', 'es'],
22 | entry: {
23 | index: resolve(__dirname, 'src/index.ts'),
24 | },
25 | },
26 | sourcemap: true,
27 | },
28 | clearScreen: false,
29 | });
30 |
--------------------------------------------------------------------------------
/packages/react-headless/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import tsconfigPaths from 'vite-tsconfig-paths';
2 | import { defineConfig } from 'vitest/config';
3 | import react from '@vitejs/plugin-react-swc';
4 |
5 | export default defineConfig({
6 | plugins: [tsconfigPaths(), react()],
7 | test: {
8 | typecheck: {
9 | enabled: true,
10 | },
11 | printConsoleTrace: true,
12 | environment: 'jsdom',
13 | globals: true,
14 | passWithNoTests: true,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/packages/react/README.md:
--------------------------------------------------------------------------------
1 | # OpenCX Widget - React
2 |
3 | The default React widget. Usable as a React component.
4 |
5 | For more information, check [the documentation](https://docs.open.cx/widget/getting-started)
6 |
--------------------------------------------------------------------------------
/packages/react/app.dev.example.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Widget } from './src';
3 | import { HandoffDefaultComponent } from './src/components/custom-components/HandoffDefaultComponent';
4 |
5 | const apiUrl = 'http://localhost:8080';
6 | const token = import.meta.env.VITE_ORG_TOKEN;
7 |
8 | export function DevApp() {
9 | return (
10 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/packages/react/embedded/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { version } from '../package.json';
4 | import type { WidgetConfig } from '@opencx/widget-core';
5 | import { Widget } from '../src';
6 |
7 | const defaultRootId = 'opencx-root';
8 |
9 | declare global {
10 | interface Window {
11 | initOpenScript: typeof initOpenScript;
12 | openCXWidgetVersion: string;
13 | }
14 | }
15 |
16 | function initOpenScript(options: WidgetConfig) {
17 | render(defaultRootId, );
18 | }
19 |
20 | window.initOpenScript = initOpenScript;
21 | window.openCXWidgetVersion = version;
22 |
23 | export function render(rootId: string, component: React.JSX.Element) {
24 | let rootElement = document.getElementById(rootId);
25 | if (!rootElement) {
26 | rootElement = document.createElement('div');
27 | rootElement.id = rootId;
28 | document.body.appendChild(rootElement);
29 | }
30 |
31 | return createRoot(rootElement).render(component);
32 | }
33 |
--------------------------------------------------------------------------------
/packages/react/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..700;1,14..32,100..700&display=swap');
2 | @import url('https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap');
3 |
4 | @tailwind base;
5 | @tailwind components;
6 | @tailwind utilities;
7 |
8 | .required:after {
9 | content: ' *';
10 | color: red;
11 | }
12 |
13 | @layer base {
14 | .no-scrollbar {
15 | -ms-overflow-style: none;
16 | scrollbar-width: none;
17 | }
18 |
19 | .no-scrollbar::-webkit-scrollbar {
20 | display: none;
21 | }
22 |
23 | * {
24 | @apply border-border no-scrollbar;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/react/index.dev.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | // create your own app.dev.tsx, refer to app.dev.example.tsx
4 | import { DevApp } from './app.dev';
5 |
6 | const rootElement = document.getElementById('root');
7 | if (!rootElement) {
8 | throw new Error('No root element found');
9 | }
10 | createRoot(rootElement).render( );
11 |
--------------------------------------------------------------------------------
/packages/react/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | OpenCX Widget
8 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/packages/react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/widget-react",
3 | "private": false,
4 | "version": "4.0.30",
5 | "type": "module",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/openchatai/widget"
9 | },
10 | "scripts": {
11 | "clean": "rm -rf node_modules dist .turbo",
12 | "clean:dist": "rm -rf dist .turbo",
13 | "dev": "vite --host",
14 | "build": "vite build -c vite.config.ts",
15 | "test": "vitest run",
16 | "type-check": "tsc --noEmit",
17 | "lint": "eslint .",
18 | "format": "prettier --write ."
19 | },
20 | "files": [
21 | "dist"
22 | ],
23 | "exports": {
24 | ".": {
25 | "types": "./dist/index.d.ts",
26 | "import": "./dist/index.js",
27 | "require": "./dist/index.cjs"
28 | }
29 | },
30 | "dependencies": {
31 | "@opencx/widget-core": "workspace:*",
32 | "@opencx/widget-react-headless": "workspace:*",
33 | "@radix-ui/react-avatar": "^1.1.0",
34 | "@radix-ui/react-dropdown-menu": "^2.1.4",
35 | "@radix-ui/react-popover": "^1.1.2",
36 | "@radix-ui/react-slot": "^1.1.0",
37 | "@radix-ui/react-switch": "^1.1.0",
38 | "@radix-ui/react-tooltip": "^1.1.2",
39 | "@uiw/react-iframe": "^1.0.3",
40 | "class-variance-authority": "^0.7.0",
41 | "clsx": "^2.1.1",
42 | "framer-motion": "^11.3.30",
43 | "lucide-react": "^0.436.0",
44 | "react-dropzone": "^14.3.5",
45 | "react-markdown": "^9.0.1",
46 | "react-use": "^17.5.1",
47 | "rehype-raw": "^7.0.0",
48 | "remark-gfm": "^4.0.0",
49 | "tailwind-merge": "^2.4.0",
50 | "tinycolor2": "^1.6.0",
51 | "zod": "^3.23.8"
52 | },
53 | "peerDependencies": {
54 | "@types/react": ">=18 <20",
55 | "@types/react-dom": ">=18 <20",
56 | "react": ">=18 <20",
57 | "react-dom": ">=18 <20"
58 | },
59 | "devDependencies": {
60 | "@opencx/eslint-config": "workspace:*",
61 | "@opencx/tsconfig": "workspace:*",
62 | "@tailwindcss/typography": "^0.5.15",
63 | "@types/tinycolor2": "^1.4.6",
64 | "postcss": "^8.4.41",
65 | "tailwindcss": "^3.4.6",
66 | "tailwindcss-animate": "^1.0.7"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/packages/react/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/packages/react/src/WidgetPopoverAnchor.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as PopoverPrimitive from '@radix-ui/react-popover';
3 | import { useDocumentDir } from '@opencx/widget-react-headless';
4 |
5 | export function WidgetPopoverAnchor() {
6 | const { dir } = useDocumentDir();
7 |
8 | return (
9 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/packages/react/src/components/AgentOrBotAvatar.tsx:
--------------------------------------------------------------------------------
1 | import type { AgentOrBotType } from '@opencx/widget-core';
2 | import React from 'react';
3 | import { Avatar, AvatarFallback, AvatarImage } from './lib/avatar';
4 | import type { AvatarProps } from '@radix-ui/react-avatar';
5 |
6 | export function AgentOrBotAvatar({
7 | agent,
8 | ...props
9 | }: AvatarProps & {
10 | agent: AgentOrBotType | undefined;
11 | }) {
12 | return (
13 |
14 |
15 | {agent?.name && (
16 |
17 | {agent?.name?.slice(0, 1)?.toUpperCase()}
18 |
19 | )}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/packages/react/src/components/AttachmentPreview.tsx:
--------------------------------------------------------------------------------
1 | import type { MessageAttachmentType } from '@opencx/widget-core';
2 | import React from 'react';
3 | import { cn } from './lib/utils/cn';
4 | import { Wobble } from './lib/wobble';
5 | import { Dialoger, DialogerContent } from './Dialoger';
6 |
7 | type Props = {
8 | attachment: MessageAttachmentType;
9 | };
10 |
11 | export function AttachmentPreview({ attachment }: Props) {
12 | const { name, size, type, url } = attachment;
13 |
14 | const isImage = type.startsWith('image/');
15 | const isVideo = type.startsWith('video/');
16 | const isAudio = type.startsWith('audio/');
17 |
18 | if (isAudio) {
19 | return (
20 |
21 |
22 |
23 |
24 | Your browser does not support the audio tag.
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | if (isVideo) {
32 | return (
33 |
34 |
35 |
36 |
37 | Your browser does not support the video tag.
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | if (!isImage && !isVideo && !isAudio) {
45 | return (
46 |
47 |
65 |
66 | );
67 | }
68 |
69 | return (
70 |
73 |
74 |
75 | {isImage && (
76 |
77 | )}
78 |
79 |
80 |
81 | }
82 | >
83 |
87 | {isImage && (
88 |
89 |
90 |
91 | )}
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/packages/react/src/components/BotOrAgentMessage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useWidget,
3 | type WidgetComponentProps,
4 | } from '@opencx/widget-react-headless';
5 | import React from 'react';
6 | import { BotOrAgentMessageDefaultComponent } from './custom-components/BotOrAgentMessageDefaultComponent';
7 |
8 | export function BotOrAgentMessage({
9 | isFirstInGroup,
10 | isLastInGroup,
11 | isAloneInGroup,
12 | ...props
13 | }: WidgetComponentProps & {
14 | isFirstInGroup: boolean;
15 | isLastInGroup: boolean;
16 | isAloneInGroup: boolean;
17 | }) {
18 | const { componentStore } = useWidget();
19 | if (props.type !== 'AGENT' && props.type !== 'AI') return null;
20 |
21 | // Try to use custom components first
22 | if (props.data.action) {
23 | const Component = componentStore.getComponent(props.data.action.name);
24 | if (Component) {
25 | return (
26 |
33 | );
34 | }
35 | }
36 |
37 | const Component = componentStore.getComponent(props.component);
38 |
39 | if (!Component) {
40 | // Fallback... just in case
41 | return (
42 |
48 | );
49 | }
50 |
51 | return (
52 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/packages/react/src/components/BotOrAgentMessageGroup.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type WidgetAgentMessage,
3 | type AgentOrBotType,
4 | type WidgetAiMessage,
5 | } from '@opencx/widget-core';
6 | import React from 'react';
7 | import { dc } from '../utils/data-component';
8 | import { AgentOrBotAvatar } from './AgentOrBotAvatar';
9 | import { BotOrAgentMessage } from './BotOrAgentMessage';
10 | import { Tooltippy } from './lib/tooltip';
11 | import { cn } from './lib/utils/cn';
12 | import { SuggestedReplyButton } from './SuggestedReplyButton';
13 | import { GroupTimestamp } from './GroupTimestamp';
14 |
15 | export function BotOrAgentMessageGroup({
16 | messages,
17 | agent,
18 | suggestedReplies,
19 | }: {
20 | messages: WidgetAiMessage[] | WidgetAgentMessage[];
21 | agent: AgentOrBotType | undefined;
22 | suggestedReplies?: string[];
23 | }) {
24 | return (
25 |
29 |
30 |
35 |
36 |
37 |
38 |
42 |
43 |
47 |
48 |
52 | {messages.map((message, index, array) => (
53 |
60 | ))}
61 |
62 |
63 |
64 |
65 | {suggestedReplies && suggestedReplies.length > 0 && (
66 |
70 | {suggestedReplies?.map((suggestion, index) => (
71 |
75 | ))}
76 |
77 | )}
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/packages/react/src/components/GroupTimestamp.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type {
3 | WidgetAiMessage,
4 | WidgetAgentMessage,
5 | WidgetUserMessage,
6 | } from '@opencx/widget-core';
7 | import { useConfig } from '@opencx/widget-react-headless';
8 | import { cn } from './lib/utils/cn';
9 |
10 | export function GroupTimestamp({
11 | messages,
12 | className,
13 | containerClassName,
14 | }: {
15 | messages: WidgetAiMessage[] | WidgetAgentMessage[] | WidgetUserMessage[];
16 | className?: string;
17 | containerClassName?: string;
18 | }) {
19 | const { timestamps } = useConfig();
20 |
21 | if (!timestamps?.perMessageGroup?.enabled) {
22 | return null;
23 | }
24 |
25 | const lastMessageTimestamp = messages[messages.length - 1]?.timestamp;
26 | if (!lastMessageTimestamp) return null;
27 |
28 | const formattedTimestamp = (() => {
29 | try {
30 | return new Date(lastMessageTimestamp).toLocaleTimeString([], {
31 | hour: '2-digit',
32 | minute: '2-digit',
33 | hour12: true,
34 | });
35 | } catch (error) {
36 | console.error(error);
37 | return null;
38 | }
39 | })();
40 | if (!formattedTimestamp) return null;
41 |
42 | return (
43 |
44 |
45 | {formattedTimestamp}
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/packages/react/src/components/MemoizedReactMarkdown.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 |
4 | export const MemoizedReactMarkdown = React.memo(
5 | ReactMarkdown,
6 | (prev, next) =>
7 | prev.children === next.children && prev.className === next.className,
8 | );
9 |
--------------------------------------------------------------------------------
/packages/react/src/components/MightSolveUserIssueSuggestedReplies.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslation } from '../hooks/useTranslation';
3 | import { SuggestedReplyButton } from './SuggestedReplyButton';
4 | import { dc } from '../utils/data-component';
5 |
6 | export function MightSolveUserIssueSuggestedReplies() {
7 | const { t } = useTranslation();
8 | const options = [t('i_need_more_help'), t('this_was_helpful')];
9 |
10 | return (
11 |
15 | {options.map((option) => (
16 |
21 | ))}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/react/src/components/PoweredByOpen.tsx:
--------------------------------------------------------------------------------
1 | import { useConfig } from '@opencx/widget-react-headless';
2 | import * as React from 'react';
3 | import { cn } from './lib/utils/cn.js';
4 | import { Wobble } from './lib/wobble.js';
5 | import { OpenLogoSvg } from './svg/OpenLogoSvg.js';
6 |
7 | export function PoweredByOpen({ className }: { className?: string }) {
8 | const { token } = useConfig();
9 |
10 | return (
11 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/packages/react/src/components/RichText.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import remarkGfm from 'remark-gfm';
3 | import { MemoizedReactMarkdown } from './MemoizedReactMarkdown';
4 | import rehypeRaw from 'rehype-raw';
5 | import { useConfig } from '@opencx/widget-react-headless';
6 |
7 | export function RichText({
8 | children,
9 | messageType,
10 | messageId,
11 | }: {
12 | children: string;
13 | messageType?: string;
14 | messageId?: string;
15 | }) {
16 | const { anchorTarget } = useConfig();
17 |
18 | return (
19 | {
26 | return (
27 |
28 | {children}
29 |
30 | );
31 | },
32 | }}
33 | // Do not pass className directly to ReactMarkdown component because that will create a container div wrapping the rich text
34 | >
35 | {children}
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/packages/react/src/components/SuggestedReplyButton.tsx:
--------------------------------------------------------------------------------
1 | import { useMessages } from '@opencx/widget-react-headless';
2 | import React from 'react';
3 | import { dc } from '../utils/data-component.js';
4 | import { Button, type ButtonProps } from './lib/button.js';
5 | import { cn } from './lib/utils/cn.js';
6 |
7 | export function SuggestedReplyButton({
8 | suggestion,
9 | className,
10 | ...props
11 | }: ButtonProps & { suggestion: string }) {
12 | const { sendMessage } = useMessages();
13 |
14 | const handleSend = () => {
15 | const trimmed = suggestion.trim();
16 | if (!trimmed) return;
17 | sendMessage({ content: trimmed });
18 | };
19 |
20 | return (
21 |
28 | {suggestion}
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/packages/react/src/components/UserMessage.tsx:
--------------------------------------------------------------------------------
1 | import type { WidgetUserMessage } from '@opencx/widget-core';
2 | import React from 'react';
3 | import { dc } from '../utils/data-component';
4 | import { AttachmentPreview } from './AttachmentPreview';
5 | import { cn } from './lib/utils/cn';
6 |
7 | export function UserMessage({
8 | message,
9 | isFirstInGroup,
10 | isLastInGroup,
11 | isAloneInGroup,
12 | }: {
13 | message: WidgetUserMessage;
14 | isFirstInGroup: boolean;
15 | isLastInGroup: boolean;
16 | isAloneInGroup: boolean;
17 | }) {
18 | return (
19 |
23 | {message.attachments && message.attachments.length > 0 && (
24 |
25 | {message.attachments?.map((attachment) => (
26 |
27 | ))}
28 |
29 | )}
30 | {message.content.length > 0 && (
31 |
53 | {message.content}
54 |
55 | )}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/packages/react/src/components/UserMessageGroup.tsx:
--------------------------------------------------------------------------------
1 | import type { WidgetUserMessage } from '@opencx/widget-core';
2 | import React from 'react';
3 | import { dc } from '../utils/data-component';
4 | import { cn } from './lib/utils/cn';
5 | import { UserMessage } from './UserMessage';
6 | import { GroupTimestamp } from './GroupTimestamp';
7 |
8 | export function UserMessageGroup({
9 | messages,
10 | }: {
11 | messages: WidgetUserMessage[];
12 | }) {
13 | return (
14 |
18 | {messages.map((message, index, array) => (
19 |
26 | ))}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/react/src/components/custom-components/BotOrAgentMessageDefaultComponent.tsx:
--------------------------------------------------------------------------------
1 | import type { OpenCxComponentNameU } from '@opencx/widget-core';
2 | import type { WidgetComponentProps } from '@opencx/widget-react-headless';
3 | import React from 'react';
4 | import { dc } from '../../utils/data-component.js';
5 | import { AttachmentPreview } from '../AttachmentPreview.js';
6 | import { cn } from '../lib/utils/cn.js';
7 | import { RichText } from '../RichText.js';
8 |
9 | export function BotOrAgentMessageDefaultComponent({
10 | data,
11 | id,
12 | type,
13 | attachments,
14 | isFirstInGroup,
15 | isLastInGroup,
16 | isAloneInGroup,
17 | dataComponentNames,
18 | classNames,
19 | }: WidgetComponentProps & {
20 | isFirstInGroup: boolean;
21 | isLastInGroup: boolean;
22 | isAloneInGroup: boolean;
23 | dataComponentNames?: {
24 | messageContainer?: OpenCxComponentNameU;
25 | message?: OpenCxComponentNameU;
26 | };
27 | classNames?: {
28 | messageContainer?: string;
29 | message?: string;
30 | };
31 | }) {
32 | if (type !== 'AI' && type !== 'AGENT') return null;
33 |
34 | const { message, variant = 'default' } = data;
35 |
36 | return (
37 |
44 | {attachments && attachments.length > 0 && (
45 |
46 | {attachments?.map((attachment) => (
47 |
48 | ))}
49 |
50 | )}
51 | {message.length > 0 && (
52 |
79 |
80 | {message}
81 |
82 |
83 | )}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/packages/react/src/components/custom-components/FallbackDefaultComponent.tsx:
--------------------------------------------------------------------------------
1 | import type { WidgetComponentProps } from '@opencx/widget-react-headless';
2 | import React from 'react';
3 |
4 | /**
5 | * The Basic Fallback component (Rendered when Debug is True and the component key is not found)
6 | */
7 | export function FallbackDefaultComponent(props: WidgetComponentProps) {
8 | return (
9 |
10 |
11 | {JSON.stringify(props, null, 1)}
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/packages/react/src/components/custom-components/HandoffDefaultComponent.tsx:
--------------------------------------------------------------------------------
1 | import type { WidgetComponentProps } from '@opencx/widget-react-headless';
2 | import React from 'react';
3 |
4 | export function HandoffDefaultComponent({ data }: WidgetComponentProps) {
5 | return (
6 |
7 |
Handoff Custom Component
8 |
9 | {JSON.stringify({ ...data }, null, 2)}
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/packages/react/src/components/custom-components/LoadingDefaultComponent.tsx:
--------------------------------------------------------------------------------
1 | import { type AgentOrBotType } from '@opencx/widget-core';
2 | import { AnimatePresence, motion } from 'framer-motion';
3 | import React from 'react';
4 | import { dc } from '../../utils/data-component';
5 | import { AgentOrBotAvatar } from '../AgentOrBotAvatar';
6 | import { MotionDiv } from '../lib/MotionDiv';
7 | import { cn } from '../lib/utils/cn';
8 |
9 | export type LoadingComponentProps = {
10 | agent: AgentOrBotType | undefined;
11 | };
12 |
13 | export function LoadingDefaultComponent({ agent }: LoadingComponentProps) {
14 | return (
15 |
16 |
20 |
21 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/DynamicIcon.tsx:
--------------------------------------------------------------------------------
1 | import { isExhaustive, type IconNameU } from '@opencx/widget-core';
2 | import {
3 | CheckCheckIcon,
4 | CheckIcon,
5 | CircleCheckBigIcon,
6 | CircleCheckIcon,
7 | CircleDashedIcon,
8 | CircleXIcon,
9 | ExpandIcon,
10 | Maximize2Icon,
11 | MaximizeIcon,
12 | Minimize2Icon,
13 | MinimizeIcon,
14 | ShrinkIcon,
15 | SquareCheckBigIcon,
16 | SquareCheckIcon,
17 | SquareXIcon,
18 | XIcon,
19 | type LucideIcon,
20 | } from 'lucide-react';
21 | import React from 'react';
22 | import { cn } from './utils/cn';
23 |
24 | const FallbackIcon = CircleDashedIcon;
25 |
26 | export function DynamicIcon({
27 | name,
28 | className,
29 | }: {
30 | name: IconNameU | undefined;
31 | className?: string;
32 | }) {
33 | const Icon: LucideIcon = (() => {
34 | switch (name) {
35 | case 'Check':
36 | return CheckIcon;
37 | case 'CheckCheck':
38 | return CheckCheckIcon;
39 | case 'CircleCheck':
40 | return CircleCheckIcon;
41 | case 'CircleCheckBig':
42 | return CircleCheckBigIcon;
43 | case 'CircleX':
44 | return CircleXIcon;
45 | case 'Expand':
46 | return ExpandIcon;
47 | case 'Maximize':
48 | return MaximizeIcon;
49 | case 'Maximize2':
50 | return Maximize2Icon;
51 | case 'Minimize':
52 | return MinimizeIcon;
53 | case 'Minimize2':
54 | return Minimize2Icon;
55 | case 'Shrink':
56 | return ShrinkIcon;
57 | case 'SquareCheck':
58 | return SquareCheckIcon;
59 | case 'SquareCheckBig':
60 | return SquareCheckBigIcon;
61 | case 'SquareX':
62 | return SquareXIcon;
63 | case 'X':
64 | return XIcon;
65 |
66 | case undefined:
67 | return FallbackIcon;
68 |
69 | default:
70 | isExhaustive(name, DynamicIcon.name);
71 | return FallbackIcon;
72 | }
73 | })();
74 |
75 | return ;
76 | }
77 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/LoadingSpinner.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { LoaderIcon } from 'lucide-react';
3 | import { cn } from './utils/cn';
4 |
5 | export function LoadingSpinner({ className }: { className?: string }) {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/MotionDiv.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { motion, type Target } from 'framer-motion';
3 | import { type ComponentProps, forwardRef } from 'react';
4 |
5 | type MotionProps = ComponentProps;
6 | type AnimationDirection = 'right' | 'left' | 'up' | 'down';
7 | export type MotionDivProps = MotionProps & {
8 | fadeIn?: AnimationDirection;
9 | distance?: number;
10 | snapExit?: boolean;
11 | overrides?: Overrides;
12 | delay?: number;
13 | };
14 |
15 | type Overrides = {
16 | initial?: Target;
17 | animate?: Target;
18 | exit?: Target;
19 | };
20 |
21 | export const ANIMATION_DISTANCE_PX = 10;
22 |
23 | const fadeInRight = (
24 | distance: number,
25 | overrides: Overrides,
26 | delay: number,
27 | ): MotionProps => ({
28 | initial: { opacity: 0, x: -distance, ...overrides.initial },
29 | animate: { opacity: 1, x: 0, ...overrides.animate, transition: { delay } },
30 | exit: { opacity: 0, x: distance, ...overrides.exit },
31 | });
32 |
33 | const fadeInLeft = (
34 | distance: number,
35 | overrides: Overrides,
36 | delay: number,
37 | ): MotionProps => ({
38 | initial: { opacity: 0, x: distance, ...overrides.initial },
39 | animate: { opacity: 1, x: 0, ...overrides.animate, transition: { delay } },
40 | exit: { opacity: 0, x: -distance, ...overrides.exit },
41 | });
42 |
43 | const fadeInUp = (
44 | distance: number,
45 | overrides: Overrides,
46 | delay: number,
47 | ): MotionProps => ({
48 | initial: { opacity: 0, y: distance, ...overrides.initial },
49 | animate: { opacity: 1, y: 0, ...overrides.animate, transition: { delay } },
50 | exit: { opacity: 0, y: -distance, ...overrides.exit },
51 | });
52 |
53 | const fadeInDown = (
54 | distance: number,
55 | overrides: Overrides,
56 | delay: number,
57 | ): MotionProps => ({
58 | initial: { opacity: 0, y: -distance, ...overrides.initial },
59 | animate: { opacity: 1, y: 0, ...overrides.animate, transition: { delay } },
60 | exit: { opacity: 0, y: distance, ...overrides.exit },
61 | });
62 |
63 | const treasureMap: Record<
64 | AnimationDirection,
65 | (distance: number, overrides: Overrides, delay: number) => MotionProps
66 | > = {
67 | right: fadeInRight,
68 | left: fadeInLeft,
69 | up: fadeInUp,
70 | down: fadeInDown,
71 | };
72 |
73 | const MotionDiv = forwardRef(
74 | (
75 | {
76 | fadeIn = 'down',
77 | distance = ANIMATION_DISTANCE_PX,
78 | children,
79 | snapExit = false,
80 | overrides = {},
81 | delay = 0,
82 | ...props
83 | },
84 | ref,
85 | ) => {
86 | const fadeInProps: MotionProps = fadeIn
87 | ? treasureMap[fadeIn](distance, overrides, delay)
88 | : {};
89 |
90 | if (
91 | snapExit &&
92 | fadeInProps.exit &&
93 | typeof fadeInProps.exit === 'object' &&
94 | !Array.isArray(fadeInProps.exit)
95 | ) {
96 | fadeInProps.exit.transition = { duration: 0 };
97 | }
98 |
99 | return (
100 |
101 | {children}
102 |
103 | );
104 | },
105 | );
106 | MotionDiv.displayName = 'MotionDiv';
107 |
108 | export { MotionDiv };
109 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/MotionDiv__VerticalReveal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { MotionDiv, type MotionDivProps } from './MotionDiv';
3 | import { cn } from './utils/cn';
4 |
5 | export const MotionDiv__VerticalReveal = React.forwardRef(
6 | (props, ref) => {
7 | return (
8 |
18 | );
19 | }
20 | );
21 | MotionDiv__VerticalReveal.displayName = 'MotionDiv__VerticalReveal';
22 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
3 | import { cn } from './utils/cn';
4 |
5 | const Avatar = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 | import { Wobble } from './wobble';
5 | import { cn } from './utils/cn';
6 | import { dc } from '../../utils/data-component';
7 |
8 | const buttonVariants = cva(
9 | cn(
10 | 'inline-flex shrink-0 items-center justify-center gap-2',
11 | 'text-sm font-medium whitespace-nowrap',
12 | 'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
13 | 'disabled:pointer-events-none disabled:opacity-50',
14 | 'active:scale-95 hover:active:scale-95',
15 | 'rounded-xl',
16 | 'transition',
17 | ),
18 | {
19 | variants: {
20 | variant: {
21 | default: 'bg-primary text-primary-foreground',
22 | destructive: 'bg-destructive text-destructive-foreground',
23 | outline: 'bg-background border',
24 | secondary: 'bg-secondary text-secondary-foreground',
25 | ghost: 'hover:bg-secondary',
26 | link: 'text-primary underline-offset-4 hover:underline',
27 | },
28 | size: {
29 | default: 'h-10 px-4 py-2',
30 | sm: 'py-2 px-3.5 text-xs',
31 | /**
32 | * This size is useful for top level buttons that needs to sit nicely inside the iframe's border radius.
33 | * Having the minimum height higher than usual (the `default` variant) will make the border radius look just right.
34 | */
35 | lg: 'min-h-12 px-4 rounded-[20px]',
36 | icon: 'h-10 w-10',
37 | fit: 'size-fit p-2',
38 | free: 'p-2',
39 | selfless: 'p-0',
40 | },
41 | },
42 | defaultVariants: {
43 | variant: 'default',
44 | size: 'default',
45 | },
46 | },
47 | );
48 |
49 | export interface ButtonProps
50 | extends React.ButtonHTMLAttributes,
51 | VariantProps {
52 | asChild?: boolean;
53 | }
54 |
55 | const Button = React.forwardRef(
56 | (
57 | { className, variant = 'default', size, asChild = false, ...props },
58 | ref,
59 | ) => {
60 | const Comp = asChild ? Slot : 'button';
61 | return (
62 |
63 |
69 |
70 | );
71 | },
72 | );
73 | Button.displayName = 'Button';
74 |
75 | export { Button, buttonVariants };
76 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useIsSmallScreen } from '../../hooks/useIsSmallScreen.js';
3 | import { cn } from './utils/cn.js';
4 | import { Wobble } from './wobble.js';
5 |
6 | export type InputProps = React.InputHTMLAttributes;
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | const { isSmallScreen } = useIsSmallScreen();
11 |
12 | return (
13 |
14 |
26 |
27 | );
28 | },
29 | );
30 | Input.displayName = 'Input';
31 |
32 | export { Input };
33 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as PopoverPrimitive from '@radix-ui/react-popover';
2 | import * as React from 'react';
3 | import { cn } from './utils/cn';
4 |
5 | const Popover = PopoverPrimitive.Root;
6 |
7 | const PopoverTrigger = PopoverPrimitive.Trigger;
8 |
9 | const PopoverContent = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef & {
12 | animate?: boolean;
13 | }
14 | >(
15 | (
16 | { className, align = 'end', sideOffset = 4, animate = true, ...props },
17 | ref,
18 | ) => (
19 |
31 | ),
32 | );
33 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
34 |
35 | export { Popover, PopoverTrigger, PopoverContent };
36 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { cn } from './utils/cn';
3 |
4 | function Skeleton({
5 | className,
6 | ...props
7 | }: React.HTMLAttributes) {
8 | return (
9 |
13 | );
14 | }
15 |
16 | export { Skeleton };
17 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SwitchPrimitives from '@radix-ui/react-switch';
3 | import { cn } from './utils/cn';
4 | import { Wobble } from './wobble';
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
11 |
19 |
25 |
26 |
27 | ));
28 | Switch.displayName = SwitchPrimitives.Root.displayName;
29 |
30 | export { Switch };
31 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
2 | import * as React from 'react';
3 | import { cn } from './utils/cn.js';
4 | import { useConfig } from '@opencx/widget-react-headless';
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ));
26 |
27 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
28 |
29 | function Tooltippy({
30 | children,
31 | content,
32 | side,
33 | align,
34 | }: {
35 | children: React.ReactNode;
36 | content: React.ReactNode;
37 | side?: TooltipPrimitive.TooltipContentProps['side'];
38 | align?: TooltipPrimitive.TooltipContentProps['align'];
39 | }) {
40 | const { disableTooltips } = useConfig();
41 | if (!content || disableTooltips) return children;
42 | return (
43 |
44 | {children}
45 |
51 | {content}
52 |
53 |
54 | );
55 | }
56 |
57 | export { TooltipProvider, Tooltippy };
58 |
--------------------------------------------------------------------------------
/packages/react/src/components/lib/utils/cn.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/packages/react/src/components/special-components/ChatBottomComponents.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSpecialComponentProps } from '../../hooks/useSpecialComponentProps';
3 |
4 | export function ChatBottomComponents() {
5 | const { props } = useSpecialComponentProps();
6 | const components = props.config.specialComponents?.chatBottomComponents;
7 |
8 | if (!components) return null;
9 |
10 | return (
11 |
12 | {components.map(({ key, component: Component }) => (
13 |
14 | ))}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/packages/react/src/components/special-components/HeaderBottomComponent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSpecialComponentProps } from '../../hooks/useSpecialComponentProps';
3 |
4 | export function HeaderBottomComponent() {
5 | const { props } = useSpecialComponentProps();
6 | const Component = props.config.specialComponents?.headerBottom;
7 |
8 | if (!Component) return null;
9 |
10 | return ;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/react/src/components/special-components/SessionResolvedComponent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSpecialComponentProps } from '../../hooks/useSpecialComponentProps';
3 |
4 | export function SessionResolvedComponent() {
5 | const { props } = useSpecialComponentProps();
6 |
7 | if (props.session?.isOpened || !props.session) return null;
8 |
9 | const Component = props.config.specialComponents?.onSessionResolved;
10 | if (!Component) return null;
11 |
12 | return ;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/react/src/components/svg/ChatBubbleSvg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { cn } from '../lib/utils/cn';
3 |
4 | export function ChatBubbleSvg({
5 | className,
6 | style,
7 | }: {
8 | className?: string;
9 | style?: React.CSSProperties;
10 | }) {
11 | return (
12 |
21 |
25 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/react/src/components/svg/OpenLogoSvg.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { cn } from '../lib/utils/cn';
3 |
4 | export function OpenLogoSvg({ className }: { className?: string }) {
5 | return (
6 |
14 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/packages/react/src/hooks/useCanvas.ts:
--------------------------------------------------------------------------------
1 | import { useMessages } from '@opencx/widget-react-headless';
2 | import { useIsSmallScreen } from './useIsSmallScreen';
3 | import { useModes } from '@opencx/widget-react-headless';
4 |
5 | export function useCanvas() {
6 | const {
7 | messagesState: { isInitialFetchLoading },
8 | } = useMessages();
9 | const { isSmallScreen } = useIsSmallScreen();
10 | const { activeMode, Component } = useModes();
11 |
12 | const isCanvasOpen =
13 | !isInitialFetchLoading && !isSmallScreen && !!activeMode && !!Component;
14 |
15 | return {
16 | isCanvasOpen,
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/packages/react/src/hooks/useIsSmallScreen.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const THRESHOLD_PIXELS = 450;
4 |
5 | export function useIsSmallScreen() {
6 | const [isSmallScreen, setIsSmallScreen] = React.useState(() => {
7 | return (window.top || window).innerWidth < THRESHOLD_PIXELS;
8 | });
9 |
10 | React.useEffect(() => {
11 | const topWindow = window.top || window;
12 |
13 | const checkScreenSize = () => {
14 | setIsSmallScreen(topWindow.innerWidth < THRESHOLD_PIXELS);
15 | };
16 |
17 | checkScreenSize();
18 |
19 | topWindow.addEventListener('resize', checkScreenSize);
20 |
21 | return () => {
22 | topWindow.removeEventListener('resize', checkScreenSize);
23 | };
24 | }, []);
25 |
26 | return { isSmallScreen };
27 | }
28 |
--------------------------------------------------------------------------------
/packages/react/src/hooks/useSetWidgetSize.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useIsSmallScreen } from './useIsSmallScreen';
3 | import { useConfig, useWidget } from '@opencx/widget-react-headless';
4 |
5 | export function useSetWidgetSizeFn() {
6 | const { contentIframeRef } = useWidget();
7 | const { inline } = useConfig();
8 |
9 | return {
10 | setWidth: (width: string) => {
11 | contentIframeRef?.current?.style.setProperty(
12 | '--opencx-widget-width',
13 | inline ? '100%' : width,
14 | );
15 | },
16 | setHeight: (height: string) => {
17 | contentIframeRef?.current?.style.setProperty(
18 | '--opencx-widget-height',
19 | inline ? '100%' : height,
20 | );
21 | },
22 | };
23 | }
24 |
25 | export function useSetWidgetSize({
26 | width,
27 | height,
28 | }: {
29 | width?: string;
30 | height?: string;
31 | }) {
32 | const { isSmallScreen } = useIsSmallScreen();
33 | const { setWidth, setHeight } = useSetWidgetSizeFn();
34 |
35 | useEffect(() => {
36 | if (width) setWidth(width);
37 |
38 | if (height) setHeight(height);
39 | }, [isSmallScreen, height, width, setWidth, setHeight]);
40 | }
41 |
--------------------------------------------------------------------------------
/packages/react/src/hooks/useSpecialComponentProps.ts:
--------------------------------------------------------------------------------
1 | import type { SpecialComponentProps } from '@opencx/widget-core';
2 | import {
3 | useSessions,
4 | useConfig,
5 | useWidget,
6 | useMessages,
7 | useWidgetRouter,
8 | } from '@opencx/widget-react-headless';
9 | import React from 'react';
10 |
11 | export function useSpecialComponentProps(): { props: SpecialComponentProps } {
12 | const {
13 | widgetCtx: { org },
14 | } = useWidget();
15 | const {
16 | sessionState: { session },
17 | } = useSessions();
18 | const config = useConfig();
19 | const {
20 | messagesState: { messages },
21 | } = useMessages();
22 | const {
23 | routerState: { screen },
24 | } = useWidgetRouter();
25 |
26 | return {
27 | props: {
28 | react: React,
29 | org,
30 | session,
31 | config,
32 | messages,
33 | currentScreen: screen,
34 | },
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/packages/react/src/hooks/useTranslation.ts:
--------------------------------------------------------------------------------
1 | import { useConfig, useDocumentDir } from '@opencx/widget-react-headless';
2 | import { useMemo } from 'react';
3 | import {
4 | getTranslation,
5 | isSupportedLanguage,
6 | type Language,
7 | type TranslationKeyU,
8 | } from '@opencx/widget-core';
9 |
10 | export function useTranslation() {
11 | const { dir: hostDocumentDir } = useDocumentDir();
12 | const config = useConfig();
13 |
14 | return useMemo(() => {
15 | const language: Language = isSupportedLanguage(config.language)
16 | ? config.language
17 | : 'en';
18 | return {
19 | t: (key: TranslationKeyU) => getTranslation(key, language, config.translationOverrides),
20 | language: language,
21 | dir: language === 'ar' ? 'rtl' : 'ltr',
22 | hostDocumentDir,
23 | };
24 | }, [config.language, hostDocumentDir]);
25 | }
26 |
--------------------------------------------------------------------------------
/packages/react/src/hooks/useWidgetContentHeight.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { useConfig, useWidget } from '@opencx/widget-react-headless';
3 |
4 | export function useWidgetContentHeight() {
5 | const { contentIframeRef } = useWidget();
6 | const { inline } = useConfig();
7 | /**
8 | * This is the element that we will observe for height changes
9 | */
10 | const observedElementRef = useRef(null);
11 |
12 | useEffect(() => {
13 | const contentRoot = contentIframeRef?.current;
14 |
15 | if (contentRoot && observedElementRef.current) {
16 | const observedElement = observedElementRef.current;
17 |
18 | let animationFrame: number;
19 | const observer = new ResizeObserver(() => {
20 | animationFrame = requestAnimationFrame(() => {
21 | const height = observedElement.offsetHeight;
22 | contentRoot.style.setProperty(
23 | '--opencx-widget-height',
24 | inline ? '100%' : `${height.toFixed(1)}px`,
25 | );
26 | });
27 | });
28 | observer.observe(observedElement);
29 |
30 | return () => {
31 | cancelAnimationFrame(animationFrame);
32 | observer.unobserve(observedElement);
33 | };
34 | }
35 | }, [contentIframeRef, inline]);
36 |
37 | return { observedElementRef };
38 | }
39 |
--------------------------------------------------------------------------------
/packages/react/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as PopoverPrimitive from '@radix-ui/react-popover';
2 | import React from 'react';
3 | import type {
4 | ExternalStorage,
5 | LiteralWidgetComponentKey,
6 | WidgetConfig,
7 | } from '@opencx/widget-core';
8 | import {
9 | useWidgetTrigger,
10 | WidgetProvider,
11 | WidgetTriggerProvider,
12 | type WidgetComponentType,
13 | } from '@opencx/widget-react-headless';
14 | import { BotOrAgentMessageDefaultComponent } from './components/custom-components/BotOrAgentMessageDefaultComponent';
15 | import { FallbackDefaultComponent } from './components/custom-components/FallbackDefaultComponent';
16 | import { LoadingDefaultComponent } from './components/custom-components/LoadingDefaultComponent';
17 | import { WidgetContent, WidgetPopoverContent } from './WidgetPopoverContent';
18 | import { WidgetPopoverTrigger } from './WidgetPopoverTrigger';
19 | import { WidgetPopoverAnchor } from './WidgetPopoverAnchor';
20 |
21 | function WidgetPopoverTriggerAndContent() {
22 | const { isOpen, setIsOpen } = useWidgetTrigger();
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | const defaultComponents: WidgetComponentType[] = [
34 | {
35 | key: 'loading' satisfies LiteralWidgetComponentKey,
36 | component: LoadingDefaultComponent,
37 | },
38 | {
39 | key: 'fallback' satisfies LiteralWidgetComponentKey,
40 | component: FallbackDefaultComponent,
41 | },
42 | {
43 | key: 'bot_message' satisfies LiteralWidgetComponentKey,
44 | component: BotOrAgentMessageDefaultComponent,
45 | },
46 | {
47 | key: 'agent_message' satisfies LiteralWidgetComponentKey,
48 | component: BotOrAgentMessageDefaultComponent,
49 | },
50 | ];
51 |
52 | const storage: ExternalStorage = {
53 | get: async (key: string) => {
54 | return localStorage.getItem(key);
55 | },
56 | set: async (key: string, value: string) => {
57 | localStorage.setItem(key, value);
58 | },
59 | remove: async (key: string) => {
60 | localStorage.removeItem(key);
61 | },
62 | };
63 |
64 | function WidgetWrapper({
65 | options,
66 | components = [],
67 | loadingComponent,
68 | }: {
69 | options: WidgetConfig;
70 | components?: WidgetComponentType[];
71 | loadingComponent?: React.ReactNode;
72 | }) {
73 | return (
74 |
80 |
81 | {options.inline ? (
82 |
83 | ) : (
84 |
85 | )}
86 |
87 |
88 | );
89 | }
90 |
91 | export { WidgetWrapper as Widget };
92 |
--------------------------------------------------------------------------------
/packages/react/src/screens/chat/AdvancedInitialMessages.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { WidgetAiMessage } from '@opencx/widget-core';
3 | import { useMessages, useConfig } from '@opencx/widget-react-headless';
4 | import { BotOrAgentMessageGroup } from '../../components/BotOrAgentMessageGroup';
5 |
6 | export function AdvancedInitialMessages() {
7 | const {
8 | messagesState: { messages },
9 | } = useMessages();
10 |
11 | const {
12 | advancedInitialMessages = [],
13 | initialQuestionsPosition,
14 | initialQuestions,
15 | bot,
16 | } = useConfig();
17 |
18 | return (
19 | <>
20 | {messages.length === 0 && advancedInitialMessages.length > 0 && (
21 |
24 | ({
25 | component: 'bot_message',
26 | data: { message },
27 | id: `${index}-${message}`,
28 | type: 'AI',
29 | timestamp: null,
30 | }) satisfies WidgetAiMessage,
31 | )}
32 | suggestedReplies={
33 | messages.length === 0 &&
34 | initialQuestionsPosition === 'below-initial-messages'
35 | ? initialQuestions
36 | : undefined
37 | }
38 | agent={bot ? { ...bot, isAi: true, id: null } : undefined}
39 | />
40 | )}
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/packages/react/src/screens/chat/ChatBannerItems.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useMessages, useConfig } from '@opencx/widget-react-headless';
3 | import { RichText } from '../../components/RichText';
4 |
5 | export function ChatBannerItems() {
6 | const {
7 | messagesState: { messages },
8 | } = useMessages();
9 | const { chatBannerItems } = useConfig();
10 |
11 | if (!chatBannerItems?.length) return null;
12 | if (
13 | messages.length > 0 &&
14 | chatBannerItems.every((item) => !item.persistent)
15 | ) {
16 | return null;
17 | }
18 |
19 | return (
20 |
21 | {chatBannerItems.map(({ message, persistent }, index) =>
22 | messages.length > 0 && !persistent ? null : (
23 |
24 | {message}
25 |
26 | ),
27 | )}
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/packages/react/src/screens/chat/ChatCanvas.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useMessages, useModes, useSessions } from '@opencx/widget-react-headless';
3 |
4 | export function ChatCanvas() {
5 | const { activeMode, Component } = useModes();
6 | const { sendMessage } = useMessages();
7 | const { createStateCheckpoint } = useSessions();
8 |
9 | const [isSendingMessage, setIsSendingMessage] = useState(false);
10 |
11 | const handleSendMessage = async (args: Parameters[0]) => {
12 | try {
13 | setIsSendingMessage(true);
14 | await sendMessage(args);
15 | } catch (error) {
16 | console.error(error);
17 | } finally {
18 | setIsSendingMessage(false);
19 | }
20 | };
21 |
22 | if (!activeMode || !Component) return null;
23 |
24 | return (
25 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/react/src/screens/chat/ChatFooterItems.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useConfig, useSessions } from '@opencx/widget-react-headless';
3 | import { AnimatePresence } from 'framer-motion';
4 | import { MotionDiv__VerticalReveal } from '../../components/lib/MotionDiv__VerticalReveal';
5 | import { RichText } from '../../components/RichText';
6 |
7 | export function ChatFooterItems() {
8 | const { sessionState } = useSessions();
9 | const { chatFooterItems } = useConfig();
10 |
11 | const isSessionResolved = !!sessionState.session && !sessionState.session.isOpened;
12 | const isSessionOpen = !isSessionResolved;
13 |
14 | return (
15 |
16 | {chatFooterItems?.map((item, i) => {
17 | if (item.showWhenSessionIsOpen === false && isSessionOpen) {
18 | return null;
19 | }
20 | if (item.showWhenSessionIsResolved === false && isSessionResolved) {
21 | return null;
22 | }
23 |
24 | return (
25 |
26 |
27 | {item.message}
28 |
29 |
30 | );
31 | })}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/packages/react/src/screens/chat/InitialMessages.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { WidgetAiMessage } from '@opencx/widget-core';
3 | import { useMessages, useConfig } from '@opencx/widget-react-headless';
4 | import { BotOrAgentMessageGroup } from '../../components/BotOrAgentMessageGroup';
5 |
6 | export function InitialMessages() {
7 | const {
8 | messagesState: { messages },
9 | } = useMessages();
10 | const config = useConfig();
11 | const {
12 | advancedInitialMessages = [],
13 | initialQuestions,
14 | initialQuestionsPosition,
15 | } = config;
16 |
17 | const initialMessages = (() => {
18 | if (advancedInitialMessages.length) return [];
19 | if (messages.length) return [];
20 | // TODO translate default welcome message
21 | if (!config.initialMessages?.length) return ['Hello, how can I help you?'];
22 | return config.initialMessages;
23 | })();
24 |
25 | return (
26 | <>
27 | {messages.length === 0 && initialMessages.length > 0 && (
28 |
31 | ({
32 | component: 'bot_message',
33 | data: { message: m },
34 | id: `${index}-${m}`,
35 | type: 'AI',
36 | timestamp: null,
37 | }) satisfies WidgetAiMessage,
38 | )}
39 | suggestedReplies={
40 | initialQuestionsPosition === 'below-initial-messages'
41 | ? initialQuestions
42 | : undefined
43 | }
44 | agent={
45 | config.bot ? { ...config.bot, isAi: true, id: null } : undefined
46 | }
47 | />
48 | )}
49 | >
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/packages/react/src/screens/index.tsx:
--------------------------------------------------------------------------------
1 | import { isExhaustive } from '@opencx/widget-core';
2 | import { useWidgetRouter } from '@opencx/widget-react-headless';
3 | import { AnimatePresence } from 'framer-motion';
4 | import React from 'react';
5 | import { MotionDiv } from '../components/lib/MotionDiv';
6 | import { ChatScreen } from './chat';
7 | import { SessionsScreen } from './sessions';
8 | import { WelcomeScreen } from './welcome';
9 |
10 | export function RootScreen() {
11 | const {
12 | routerState: { screen },
13 | } = useWidgetRouter();
14 |
15 | return (
16 |
17 |
18 | {(() => {
19 | switch (screen) {
20 | case 'welcome':
21 | return (
22 |
28 |
29 |
30 | );
31 |
32 | case 'sessions':
33 | return (
34 |
40 |
41 |
42 | );
43 |
44 | case 'chat':
45 | return (
46 |
52 |
53 |
54 | );
55 | default: {
56 | isExhaustive(screen, RootScreen.name);
57 | return null;
58 | }
59 | }
60 | })()}
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/packages/react/src/utils/data-component.ts:
--------------------------------------------------------------------------------
1 | import type { OpenCxComponentNameU } from '@opencx/widget-core';
2 |
3 | /**
4 | * A silly util to help with the component name type safety.
5 | */
6 | export function dc(componentName: OpenCxComponentNameU) {
7 | return { 'data-component': componentName };
8 | }
9 |
--------------------------------------------------------------------------------
/packages/react/src/utils/group-messages-by-type.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type WidgetAgentMessage,
3 | type WidgetAiMessage,
4 | type WidgetMessageU,
5 | type WidgetSystemMessageU,
6 | type WidgetUserMessage,
7 | } from '@opencx/widget-core';
8 |
9 | export function groupMessagesByType(
10 | messages: WidgetMessageU[],
11 | ): WidgetMessageU[][] {
12 | const result: WidgetMessageU[][] = [];
13 | let currentGroup: WidgetMessageU[] | null = null;
14 |
15 | messages.forEach((message) => {
16 | if (
17 | // Start a new group if the type changes
18 | currentGroup?.[0]?.type !== message.type ||
19 | // Start a new group if the agent changes
20 | (currentGroup[0]?.type === 'AGENT' &&
21 | message.type === 'AGENT' &&
22 | (message.agent?.id !== currentGroup[0].agent?.id ||
23 | message.agent?.name !== currentGroup[0].agent?.name))
24 | ) {
25 | currentGroup = [];
26 | result.push(currentGroup);
27 | }
28 |
29 | currentGroup.push(message);
30 | });
31 |
32 | return result;
33 | }
34 |
35 | export function isUserMessageGroup(
36 | messages: WidgetMessageU[],
37 | ): messages is WidgetUserMessage[] {
38 | return messages?.[0]?.type === 'USER';
39 | }
40 |
41 | export function isBotMessageGroup(
42 | messages: WidgetMessageU[],
43 | ): messages is WidgetAiMessage[] {
44 | return messages?.[0]?.type === 'AI';
45 | }
46 |
47 | export function isAgentMessageGroup(
48 | messages: WidgetMessageU[],
49 | ): messages is WidgetAgentMessage[] {
50 | return messages?.[0]?.type === 'AGENT';
51 | }
52 |
53 | export function isSystemMessageGroup(
54 | messages: WidgetMessageU[],
55 | ): messages is WidgetSystemMessageU[] {
56 | return messages?.[0]?.type === 'SYSTEM';
57 | }
58 |
--------------------------------------------------------------------------------
/packages/react/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import animate from 'tailwindcss-animate';
2 | import typography from '@tailwindcss/typography';
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | export default {
6 | content: [
7 | './src/**/*.{html,js,ts,jsx,tsx}',
8 | ],
9 | theme: {
10 | extend: {
11 | zIndex: {
12 | max: 9999,
13 | },
14 | colors: {
15 | primary: 'hsl(var(--opencx-primary))',
16 | 'primary-foreground': 'hsl(var(--opencx-primary-foreground))',
17 |
18 | foreground: 'hsl(var(--opencx-foreground))',
19 | background: 'hsl(var(--opencx-background))',
20 |
21 | accent: 'hsl(var(--opencx-accent))',
22 | 'accent-foreground': 'hsl(var(--opencx-accent-foreground))',
23 |
24 | secondary: 'hsl(var(--opencx-secondary))',
25 | 'secondary-foreground': 'hsl(var(--opencx-secondary-foreground))',
26 |
27 | muted: 'hsl(var(--opencx-muted))',
28 | 'muted-foreground': 'hsl(var(--opencx-muted-foreground))',
29 |
30 | destructive: 'hsl(var(--opencx-destructive))',
31 | 'destructive-foreground': 'hsl(var(--opencx-destructive-foreground))',
32 |
33 | input: 'hsl(var(--opencx-input))',
34 | border: 'hsl(var(--opencx-border))',
35 | ring: 'hsl(var(--opencx-ring))',
36 | },
37 | },
38 | fontFamily: {
39 | inter: ['Inter', 'Rubik', 'serif', 'sans-serif'],
40 | },
41 | },
42 | plugins: [animate, typography],
43 | };
44 |
--------------------------------------------------------------------------------
/packages/react/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@opencx/tsconfig/tsconfig.base.json",
3 | "exclude": ["node_modules", "dist", "dist-embed"]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/react/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/react/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path';
2 | import react from '@vitejs/plugin-react-swc';
3 | import { defineConfig } from 'vite';
4 | import dts from 'vite-plugin-dts';
5 | import tsconfigPaths from 'vite-tsconfig-paths';
6 | import { name } from './package.json';
7 | import { externalizeDeps } from 'vite-plugin-externalize-deps';
8 |
9 | export default defineConfig({
10 | plugins: [
11 | tsconfigPaths(),
12 | dts({
13 | insertTypesEntry: true,
14 | include: ['src'],
15 | }),
16 | react(),
17 | externalizeDeps(),
18 | ],
19 | server: {
20 | port: 3005,
21 | },
22 | build: {
23 | outDir: 'dist',
24 | lib: {
25 | name,
26 | formats: ['cjs', 'es'],
27 | entry: {
28 | index: resolve(__dirname, 'src/index.tsx'),
29 | },
30 | },
31 | sourcemap: true,
32 | },
33 | clearScreen: false,
34 | });
35 |
--------------------------------------------------------------------------------
/packages/react/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import tsconfigPaths from 'vite-tsconfig-paths';
2 | import { defineConfig } from 'vitest/config';
3 | import react from '@vitejs/plugin-react-swc';
4 |
5 | export default defineConfig({
6 | plugins: [tsconfigPaths(), react()],
7 | test: {
8 | typecheck: {
9 | enabled: true,
10 | },
11 | printConsoleTrace: true,
12 | environment: 'jsdom',
13 | globals: true,
14 | passWithNoTests: true,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/packages/tsconfig/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @opencx/tsconfig
2 |
3 | ## 0.0.2
4 |
5 | ### Patch Changes
6 |
7 | - fix avatar url for bot persistable initial messages
8 |
9 | ## 0.0.1
10 |
11 | ### Patch Changes
12 |
13 | - add proper options suitable for a monorepo
14 |
--------------------------------------------------------------------------------
/packages/tsconfig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@opencx/tsconfig",
3 | "version": "0.0.2",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/tsconfig/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | // These compiler options taken from https://www.totaltypescript.com/tsconfig-cheat-sheet
4 | "compilerOptions": {
5 | /* Base Options: */
6 | "esModuleInterop": true,
7 | "skipLibCheck": true,
8 | "target": "es5",
9 | "allowJs": true,
10 | "resolveJsonModule": true,
11 | "moduleDetection": "force",
12 | "isolatedModules": true,
13 | "verbatimModuleSyntax": true,
14 |
15 | /* Strictness */
16 | "strict": true,
17 | "noUncheckedIndexedAccess": true,
18 | "noImplicitOverride": true,
19 | "noUnusedParameters": true,
20 |
21 | /* AND if you're building for a library: */
22 | "declaration": true,
23 | /* AND if you're building for a library in a monorepo: */
24 | "composite": true,
25 | "declarationMap": true,
26 |
27 | /* If NOT transpiling with TypeScript: */
28 | "module": "preserve",
29 | "noEmit": true,
30 |
31 | /* If your code runs in the DOM: */
32 | "lib": ["es2023", "dom", "dom.iterable"],
33 |
34 | // Additional options outside Matt Pocock's template
35 | "jsx": "react",
36 | "types": ["vitest/globals"]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/*
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalEnv": ["*"],
4 | "ui": "stream",
5 | "globalDependencies": [".env"],
6 | "tasks": {
7 | "clean": {},
8 | "clean:dist": {},
9 | "build": {
10 | "persistent": false,
11 | "dependsOn": ["^build"],
12 | "outputs": ["dist/**", "dist-embed/**"]
13 | },
14 | "type-check": {},
15 | "test": {},
16 | "dev:prepare": {
17 | "persistent": false,
18 | "dependsOn": [
19 | "@opencx/widget-core#build",
20 | "@opencx/widget-react-headless#build"
21 | ]
22 | },
23 | "dev": {
24 | "persistent": true,
25 | "dependsOn": ["dev:prepare"]
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | projects: ['packages/*'],
6 | },
7 | })
--------------------------------------------------------------------------------