├── .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 |
13 | 14 | Vite logo 15 | 16 | 17 | React logo 18 | 19 |
20 |

Vite + React

21 |
22 | 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 |
13 | 14 | Vite logo 15 | 16 | 17 | React logo 18 | 19 |
20 |

Vite + React

21 |
22 | 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 | 26 |
27 |
28 | ); 29 | } 30 | 31 | if (isVideo) { 32 | return ( 33 | 34 |
35 | 39 |
40 |
41 | ); 42 | } 43 | 44 | if (!isImage && !isVideo && !isAudio) { 45 | return ( 46 | 47 |
48 |
49 | 58 | {name} 59 | 60 | 61 | {(size / 1024).toFixed(2)} KB 62 | 63 |
64 |
65 |
66 | ); 67 | } 68 | 69 | return ( 70 | 73 | 74 |
75 | {isImage && ( 76 | {name} 77 | )} 78 |
79 |
80 | 81 | } 82 | > 83 | 87 | {isImage && ( 88 |
89 | {name} 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 | 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 | }) --------------------------------------------------------------------------------