From 6ada15b336b785c2200ca82c60b2cf76b3f27919 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Wed, 4 Nov 2026 02:36:03 +0530 Subject: [PATCH] feat(react): add component and useChatCops() hook (#573) --- .changeset/react-component.md | 6 + .size-limit.json | 6 + api/react.api.md | 97 ++++++ packages/react/package.json | 58 ++++ packages/react/src/index.ts | 135 +++++++++ packages/react/tests/react.test.tsx | 352 ++++++++++++++++++++++ packages/react/tsconfig.json | 14 + packages/react/vitest.config.ts | 9 + pnpm-lock.yaml | 135 +++++++++ scripts/check-doc-samples.mjs | 1 + vitest.config.ts | 1 + website/astro.config.mjs | 1 + website/src/content/docs/widget/react.mdx | 106 +++++++ 13 files changed, 921 insertions(+) create mode 100644 .changeset/react-component.md create mode 100644 api/react.api.md create mode 100644 packages/react/package.json create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/tests/react.test.tsx create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/vitest.config.ts create mode 100644 website/src/content/docs/widget/react.mdx diff --git a/.changeset/react-component.md b/.changeset/react-component.md new file mode 100644 index 00000000..8ef59384 --- /dev/null +++ b/.changeset/react-component.md @@ -0,0 +1,6 @@ +--- +"@chatcops/react": minor +--- + +Add `@chatcops/react` with `` component and `useChatCops()` hook for React and Next.js applications. + diff --git a/.size-limit.json b/.size-limit.json index 087214b7..cb39d2dd 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -34,5 +34,11 @@ "path": "packages/widget/dist/chatcops.esm.js", "limit": "32 KB", "gzip": true + }, + { + "name": "react (@chatcops/react)", + "path": "packages/react/dist/index.js", + "limit": "1 KB", + "gzip": true } ] diff --git a/api/react.api.md b/api/react.api.md new file mode 100644 index 00000000..cad32aa3 --- /dev/null +++ b/api/react.api.md @@ -0,0 +1,97 @@ +# @chatcops/react public API + +Made by `node scripts/api-snapshot.mjs` from the built types. Don't edit it by hand: change the code, build, and run the script again. + +## `@chatcops/react` + +```ts +export declare function ChatCops(props: ChatCopsProps): null; + +export interface ChatCopsHandle { + open: () => void; + close: () => void; + toggle: () => void; + send: (text: string, options?: { + open?: boolean; + }) => Promise; + update: (changes: WidgetUpdate) => void; +} + +export type ChatCopsProps = WidgetConfig & { + onClose?: () => void; + onError?: (error: Error) => void; + onLeadCaptured?: (lead?: Record) => void; + onMessage?: (message: MessageData) => void; + onOpen?: () => void; + onPreChatSubmit?: (data: Record) => void; +}; + +declare function _default(props: ChatCopsProps): null; +export { _default as default }; + +export interface MessageData { + id: string; + role: 'user' | 'assistant'; + content: string; + status?: MessageStatus; + errorType?: MessageErrorType; + errorText?: string; + sources?: Array<{ + title?: string; + url: string; + }>; +} + +export declare function useChatCops(): ChatCopsHandle; + +export interface WidgetConfig { + apiUrl: string; + nonce?: string; + timeout?: number; + turnstileSiteKey?: string; + userToken?: UserToken; + mode?: 'popup' | 'inline'; + siteId?: string; + container?: string | HTMLElement; + theme?: ThemeConfig; + branding?: { + avatar?: string; + name?: string; + subtitle?: string; + }; + welcomeMessage?: string; + disclosure?: false | { + learnMoreUrl?: string; + text?: string; + }; + suggestions?: string[]; + welcomeBubble?: { + delay?: number; + showOnce?: boolean; + text: string; + }; + placeholder?: string; + persistence?: Persistence; + historyTtlDays?: number; + maxMessages?: number; + pageContext?: boolean; + autoOpen?: boolean | number; + locale?: string; + dir?: 'ltr' | 'rtl' | 'auto'; + strings?: Partial; + preChatForm?: PreChatFormConfig; + onOpen?: () => void; + onClose?: () => void; + onMessage?: (message: MessageData) => void; + onError?: (error: Error) => void; + onLeadCaptured?: (leadData?: Record) => void; + onPreChatSubmit?: (data: Record) => void; + waitForConsent?: boolean; + onConsentRequest?: () => void; + privacyUrl?: string; +} + +export type WidgetUpdate = Pick & { + theme?: Omit; +}; +``` diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..bf3f0376 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,58 @@ +{ + "name": "@chatcops/react", + "version": "1.1.1", + "description": "React component and hooks for ChatCops", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "peerDependencies": { + "@chatcops/widget": "workspace:^", + "react": ">=18" + }, + "peerDependenciesMeta": { + "@chatcops/widget": { + "optional": false + }, + "react": { + "optional": false + } + }, + "devDependencies": { + "@chatcops/widget": "workspace:*", + "@testing-library/react": "^16.2.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "happy-dom": "^20.14.5", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.7.0", + "vitest": "^4.1.11" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codercops/chatcops.git", + "directory": "packages/react" + }, + "license": "MIT", + "engines": { + "node": ">=22" + } +} + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..d9bed2ac --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef } from 'react'; +import type { MessageData, WidgetConfig, WidgetUpdate } from '@chatcops/widget'; + +export type ChatCopsProps = WidgetConfig & { + onOpen?: () => void; + onClose?: () => void; + onMessage?: (message: MessageData) => void; + onError?: (error: Error) => void; + onLeadCaptured?: (lead?: Record) => void; + onPreChatSubmit?: (data: Record) => void; +}; + +export interface ChatCopsHandle { + open: () => void; + close: () => void; + toggle: () => void; + send: (text: string, options?: { open?: boolean }) => Promise; + update: (changes: WidgetUpdate) => void; +} + +type WidgetModule = typeof import('@chatcops/widget').default; + +let W: WidgetModule | undefined, P: Promise | undefined; + +const load = async (): Promise => { + if (!P) P = import('@chatcops/widget').then((m) => m.default); + W = await P; + return W; +}; + +const EV = 'open,close,message,error,leadCaptured,preChatSubmit'.split(',') as Array< + 'open' | 'close' | 'message' | 'error' | 'leadCaptured' | 'preChatSubmit' +>; +const EK = new Set(EV.map((e) => `on${e[0].toUpperCase()}${e.slice(1)}`)); +const UK = new Set('branding,welcomeMessage,disclosure,suggestions,placeholder,strings,privacyUrl,theme'.split(',')); + +const eq = (a: unknown, b: unknown): boolean => + Object.is(a, b) || (typeof a === 'object' && typeof b === 'object' && JSON.stringify(a) === JSON.stringify(b)); + +const initCfg = (props: ChatCopsProps): WidgetConfig => { + const c = { ...props } as Record; + for (const k of EK) delete c[k]; + return c as unknown as WidgetConfig; +}; + +const diff = ( + a: Record, + b: Record, +): { reinit?: boolean; update?: WidgetUpdate } => { + if ((a.theme as Record | undefined)?.position !== (b.theme as Record | undefined)?.position) { + return { reinit: true }; + } + const u: Record = {}; + let has = false; + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + if (!EK.has(k)) { + if (UK.has(k)) { + if (!eq(a[k], b[k])) { + has = true; + if (b[k] !== undefined) u[k] = b[k]; + } + } else if (!eq(a[k], b[k])) { + return { reinit: true }; + } + } + } + return has ? { update: u as WidgetUpdate } : {}; +}; + +export function ChatCops(props: ChatCopsProps): null { + const ref = useRef(props); + ref.current = props; + const prev = useRef(props); + const ok = useRef(false); + + useEffect(() => { + let act = true, + un: Array<() => void> = []; + + load().then((w) => { + if (!act) return; + un = EV.map((e) => + w.on( + e, + ((...args: unknown[]) => + (ref.current as unknown as Record void) | undefined>)[ + `on${e[0].toUpperCase()}${e.slice(1)}` + ]?.(...args)) as never, + ), + ); + w.init(initCfg(ref.current)); + prev.current = ref.current; + ok.current = true; + }); + + return () => { + act = false; + ok.current = false; + for (const f of un) f(); + W?.destroy(); + }; + }, []); + + useEffect(() => { + if (!ok.current || !W) return; + const d = diff( + prev.current as unknown as Record, + props as unknown as Record, + ); + if (d.reinit) { + prev.current = props; + W.init(initCfg(props)); + } else if (d.update) { + W.update(d.update); + prev.current = props; + } + }); + + return null; +} + +const handle: ChatCopsHandle = { + open: () => W?.open(), + close: () => W?.close(), + toggle: () => W?.toggle(), + send: (t: string, o?: { open?: boolean }) => W?.sendMessage(t, o) ?? Promise.resolve(), + update: (c: WidgetUpdate) => W?.update(c), +}; + +export function useChatCops(): ChatCopsHandle { + return handle; +} + +export default ChatCops; +export type { WidgetConfig, WidgetUpdate, MessageData }; diff --git a/packages/react/tests/react.test.tsx b/packages/react/tests/react.test.tsx new file mode 100644 index 00000000..cea8d49e --- /dev/null +++ b/packages/react/tests/react.test.tsx @@ -0,0 +1,352 @@ +import { StrictMode } from 'react'; +import { renderToString } from 'react-dom/server'; +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MessageData } from '@chatcops/widget'; + +type EventHandler = (...args: unknown[]) => void; + +const handlers = new Map>(); + +const mockWidget = { + init: vi.fn(), + destroy: vi.fn(), + update: vi.fn(), + open: vi.fn(), + close: vi.fn(), + toggle: vi.fn(), + sendMessage: vi.fn().mockResolvedValue(undefined), + on: vi.fn((event: string, handler: EventHandler) => { + let set = handlers.get(event); + if (!set) { + set = new Set(); + handlers.set(event, set); + } + set.add(handler); + return () => { + mockWidget.off(event, handler); + }; + }), + off: vi.fn((event: string, handler: EventHandler) => { + handlers.get(event)?.delete(handler); + }), + emit(event: string, ...args: unknown[]) { + handlers.get(event)?.forEach((h) => { + h(...args); + }); + }, + getHandlers(event: string) { + let set = handlers.get(event); + if (!set) { + set = new Set(); + handlers.set(event, set); + } + return set; + }, + clearHandlers() { + handlers.clear(); + }, +}; + +vi.mock('@chatcops/widget', () => ({ + default: mockWidget, +})); + +import { ChatCops, useChatCops } from '../src/index.js'; + +describe('@chatcops/react', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('useChatCops calls do nothing before the widget has loaded', async () => { + const { result } = renderHook(() => useChatCops()); + expect(() => result.current.open()).not.toThrow(); + expect(() => result.current.close()).not.toThrow(); + expect(() => result.current.toggle()).not.toThrow(); + expect(() => result.current.update({ welcomeMessage: 'hi' })).not.toThrow(); + await expect(result.current.send('test')).resolves.toBeUndefined(); + expect(mockWidget.open).not.toHaveBeenCalled(); + expect(mockWidget.sendMessage).not.toHaveBeenCalled(); + }); + + it('server-rendering returns an empty string and does not throw', () => { + const html = renderToString(); + expect(html).toBe(''); + expect(mockWidget.init).not.toHaveBeenCalled(); + }); + + it('mounts one widget and strips callback props from config', async () => { + render( + {}} + onClose={() => {}} + />, + ); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledTimes(1); + }); + + expect(mockWidget.init).toHaveBeenCalledWith({ + apiUrl: '/api/chat', + branding: { name: 'Acme' }, + }); + }); + + it('StrictMode leaves exactly one widget and one set of listeners', async () => { + mockWidget.init.mockClear(); + mockWidget.destroy.mockClear(); + mockWidget.clearHandlers(); + + render( + + + , + ); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalled(); + }); + + for (const event of ['open', 'close', 'message', 'error', 'leadCaptured', 'preChatSubmit']) { + expect(mockWidget.getHandlers(event).size).toBe(1); + } + }); + + it('unmounting destroys the widget and removes all listeners', async () => { + mockWidget.clearHandlers(); + const { unmount } = render(); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalled(); + }); + + unmount(); + expect(mockWidget.destroy).toHaveBeenCalled(); + + for (const event of ['open', 'close', 'message', 'error', 'leadCaptured', 'preChatSubmit']) { + expect(mockWidget.getHandlers(event).size).toBe(0); + } + }); + + it('a branding change calls update() and not init()', async () => { + const { rerender } = render(); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledTimes(1); + }); + + mockWidget.init.mockClear(); + mockWidget.update.mockClear(); + + rerender(); + + await waitFor(() => { + expect(mockWidget.update).toHaveBeenCalledWith({ + branding: { name: 'Acme Corp' }, + }); + }); + expect(mockWidget.init).not.toHaveBeenCalled(); + }); + + it('other update props call update() and not init()', async () => { + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledTimes(1); + }); + + mockWidget.init.mockClear(); + mockWidget.update.mockClear(); + + rerender( + , + ); + + await waitFor(() => { + expect(mockWidget.update).toHaveBeenCalledWith( + expect.objectContaining({ + welcomeMessage: 'Welcome!', + suggestions: ['Pricing', 'Docs'], + placeholder: 'Type a message...', + privacyUrl: 'https://example.com/privacy2', + disclosure: { text: 'AI Bot' }, + theme: { tokens: { primary: '#111' } }, + }), + ); + }); + expect(mockWidget.init).not.toHaveBeenCalled(); + }); + + it('an apiUrl change inits again', async () => { + const { rerender } = render(); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledTimes(1); + }); + + mockWidget.init.mockClear(); + + rerender(); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledWith(expect.objectContaining({ apiUrl: '/api/v2' })); + }); + }); + + it('a theme.position change inits again', async () => { + const { rerender } = render( + , + ); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledTimes(1); + }); + + mockWidget.init.mockClear(); + mockWidget.update.mockClear(); + + rerender(); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledWith( + expect.objectContaining({ + theme: { position: 'bottom-left' }, + }), + ); + }); + expect(mockWidget.update).not.toHaveBeenCalled(); + }); + + it('a mode change inits again', async () => { + const { rerender } = render(); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledTimes(1); + }); + + mockWidget.init.mockClear(); + + rerender(); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'inline', + container: '#container', + }), + ); + }); + }); + + it('onLeadCaptured fires and survives re-renders', async () => { + const lead1 = vi.fn(); + const lead2 = vi.fn(); + mockWidget.on.mockClear(); + + const { rerender } = render(); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalled(); + }); + + const initialOnCalls = mockWidget.on.mock.calls.length; + + rerender(); + expect(mockWidget.on).toHaveBeenCalledTimes(initialOnCalls); + + act(() => { + mockWidget.emit('leadCaptured', { email: 'user@example.com' }); + }); + + expect(lead1).not.toHaveBeenCalled(); + expect(lead2).toHaveBeenCalledWith({ email: 'user@example.com' }); + }); + + it('all widget event props fire and survive re-renders', async () => { + const onOpen = vi.fn(); + const onClose = vi.fn(); + const onMessage = vi.fn(); + const onError = vi.fn(); + const onPreChatSubmit = vi.fn(); + + render( + , + ); + + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalled(); + }); + + act(() => { + mockWidget.emit('open'); + mockWidget.emit('close'); + mockWidget.emit('message', { id: 'm1', role: 'user', content: 'hello' } as MessageData); + mockWidget.emit('error', new Error('boom')); + mockWidget.emit('preChatSubmit', { topic: 'billing' }); + }); + + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledWith({ id: 'm1', role: 'user', content: 'hello' }); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + expect(onPreChatSubmit).toHaveBeenCalledWith({ topic: 'billing' }); + }); + + it('useChatCops().send() reaches sendMessage and all handle methods work', async () => { + function TestApp() { + const chat = useChatCops(); + return ( +
+ + + + + + +
+ ); + } + + const { getByText } = render(); + await waitFor(() => { + expect(mockWidget.init).toHaveBeenCalled(); + }); + + act(() => { + getByText('Open').click(); + getByText('Close').click(); + getByText('Toggle').click(); + getByText('Ask').click(); + getByText('Update').click(); + }); + + expect(mockWidget.open).toHaveBeenCalledTimes(1); + expect(mockWidget.close).toHaveBeenCalledTimes(1); + expect(mockWidget.toggle).toHaveBeenCalledTimes(1); + expect(mockWidget.sendMessage).toHaveBeenCalledWith('Is this in stock?', { open: false }); + expect(mockWidget.update).toHaveBeenCalledWith({ placeholder: 'Ask here' }); + }); +}); diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 00000000..92abce0d --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx", + "sourceMap": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "references": [ + { "path": "../widget" } + ] +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..dbf437f4 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + }, +}); + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db7db678..ad8846b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,36 @@ importers: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.5)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.3.2(@types/node@22.20.5)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(yaml@2.9.1)) + packages/react: + devDependencies: + '@chatcops/widget': + specifier: workspace:* + version: link:../widget + '@testing-library/react': + specifier: ^16.2.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + happy-dom: + specifier: ^20.14.5 + version: 20.14.5 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.3.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(vite@8.3.2(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(yaml@2.9.1)) + packages/server: dependencies: '@chatcops/core': @@ -500,6 +530,10 @@ packages: peerDependencies: playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -508,6 +542,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} @@ -2091,9 +2129,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2318,6 +2378,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2331,6 +2395,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2629,6 +2696,9 @@ packages: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3150,6 +3220,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.2: resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true @@ -3358,6 +3431,10 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3901,6 +3978,10 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3951,6 +4032,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -5017,10 +5101,18 @@ snapshots: axe-core: 4.14.0 playwright-core: 1.64.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 @@ -6289,11 +6381,34 @@ snapshots: tailwindcss: 4.3.3 vite: 8.3.2(@types/node@25.3.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(yaml@2.9.1) + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6542,6 +6657,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-styles@5.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -6555,6 +6672,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-iterate@2.0.1: @@ -6876,6 +6997,8 @@ snapshots: direction@2.0.1: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -7643,6 +7766,8 @@ snapshots: js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + js-yaml@3.15.2: dependencies: argparse: 1.0.10 @@ -7804,6 +7929,8 @@ snapshots: lru-cache@11.5.3: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -8623,6 +8750,12 @@ snapshots: prettier@2.8.8: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} process-ancestry@0.1.0: {} @@ -8680,6 +8813,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@17.0.2: {} + react@19.2.4: {} read-yaml-file@1.1.0: diff --git a/scripts/check-doc-samples.mjs b/scripts/check-doc-samples.mjs index e2641c71..e8d0ff69 100644 --- a/scripts/check-doc-samples.mjs +++ b/scripts/check-doc-samples.mjs @@ -142,6 +142,7 @@ try { '@chatcops/store-upstash-vector': [packagePath('store-upstash-vector')], '@chatcops/ingest': [packagePath('ingest')], '@chatcops/cli': [packagePath('cli')], + '@chatcops/react': [packagePath('react')], express: [join(root, 'packages', 'server', 'node_modules', '@types', 'express', 'index.d.ts')], '@neondatabase/serverless': [join(root, 'packages', 'store-postgres', 'node_modules', '@neondatabase', 'serverless')], '@upstash/vector': [join(root, 'packages', 'store-upstash-vector', 'node_modules', '@upstash', 'vector')], diff --git a/vitest.config.ts b/vitest.config.ts index 9ab0ac3e..2f1c0e95 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ 'packages/widget/src/**': { lines: 90, branches: 80 }, 'packages/ingest/src/**': { lines: 85, branches: 80 }, 'packages/cli/src/**': { lines: 85, branches: 80 }, + 'packages/react/src/**': { lines: 85, branches: 80 }, // Store packages start strict: the conformance suite covers most paths. // It needs the databases CI runs, so locally (where those tests skip // without CHATCOPS_TEST_* set) the store floor only applies in CI. diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 2bd1e6f4..08e3da05 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -120,6 +120,7 @@ export default defineConfig({ label: 'Widget', items: [ { label: 'Configuration', slug: 'widget/configuration' }, + { label: 'React', slug: 'widget/react' }, { label: 'Pre-chat Form', slug: 'widget/pre-chat-form' }, { label: 'API Reference', slug: 'widget/api' }, { label: 'Theming', slug: 'widget/theming' }, diff --git a/website/src/content/docs/widget/react.mdx b/website/src/content/docs/widget/react.mdx new file mode 100644 index 00000000..8ef854f5 --- /dev/null +++ b/website/src/content/docs/widget/react.mdx @@ -0,0 +1,106 @@ +--- +title: React +description: React component and hooks for the ChatCops widget. +--- + +The `@chatcops/react` package provides a `` component and `useChatCops()` hook for React and Next.js applications. It manages widget initialization, prop updates, event subscriptions, cleanup on unmount, and React StrictMode development safely without touching `window` on the server. + +## Installation + +Install `@chatcops/react` along with `@chatcops/widget`: + +```bash +npm install @chatcops/react @chatcops/widget +``` + +## Quick Start + +In Next.js (App Router) or any React app, place `` in your root layout: + +```tsx +'use client'; + +import { ChatCops, useChatCops } from '@chatcops/react'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + console.log('Lead captured:', lead)} + /> + + + ); +} +``` + +Trigger actions anywhere in your application using `useChatCops()`: + +```tsx +'use client'; + +import { useChatCops } from '@chatcops/react'; + +export function AskProductButton({ productName }: { productName: string }) { + const chat = useChatCops(); + + return ( + + ); +} +``` + +## `` Component + +`` renders nothing (`null`) to the DOM. It loads `@chatcops/widget` dynamically in an effect on the client, making it safe for Server-Side Rendering (SSR). + +> **Important**: Render only one `` component per page. A second instance replaces the first, matching `ChatCops.init()` behavior. + +### Props + +`` accepts all options from `WidgetConfig` (such as `apiUrl`, `branding`, `theme`, `mode`, `container`, `welcomeMessage`, `placeholder`, `suggestions`, `disclosure`, `privacyUrl`, `waitForConsent`), plus callback event props: + +| Prop | Type | Description | +| :--- | :--- | :--- | +| `apiUrl` | `string` | URL of your ChatCops server endpoint (required). | +| `branding` | `{ name?, avatar?, subtitle? }` | Bot name, avatar URL, and subtitle. | +| `theme` | `ThemeConfig` | Accent colors, position (`bottom-right`, `bottom-left`), font family, border radius. | +| `onOpen` | `() => void` | Fires when the chat window opens. | +| `onClose` | `() => void` | Fires when the chat window closes. | +| `onMessage` | `(message: MessageData) => void` | Fires on visitor messages and assistant replies. | +| `onError` | `(error: Error) => void` | Fires when an error occurs during chat. | +| `onLeadCaptured` | `(lead?: Record) => void` | Fires when a visitor submits lead details. | +| `onPreChatSubmit` | `(data: Record) => void` | Fires when the pre-chat form is submitted. | + +### Prop Updates + +When props change: + +- Props supported by `update()` (`branding`, `welcomeMessage`, `suggestions`, `placeholder`, `strings`, `theme` color tokens, `privacyUrl`, `disclosure`) are applied smoothly via `ChatCops.update()`. +- Changing structural props such as `apiUrl`, `mode`, `container`, or `theme.position` destroys the previous widget and re-initializes cleanly. +- Event handlers are kept in refs: changing an inline arrow function prop on re-render never resubscribes listeners or recreates the widget. + +## `useChatCops()` Hook + +`useChatCops()` returns an object with helper methods to control the widget: + +```tsx +const chat = useChatCops(); +``` + +| Method | Type | Description | +| :--- | :--- | :--- | +| `chat.open()` | `() => void` | Opens the chat panel. | +| `chat.close()` | `() => void` | Closes the chat panel. | +| `chat.toggle()` | `() => void` | Toggles the chat panel open or closed. | +| `chat.send(text, options?)` | `(text: string, options?: { open?: boolean }) => Promise` | Sends a message as the visitor. Opens the chat by default unless `open: false` is passed. Resolves when the assistant reply finishes streaming. | +| `chat.update(changes)` | `(changes: WidgetUpdate) => void` | Updates theme, branding, or strings on the active chat. | + +Before the widget bundle has loaded on the client, these methods safely do nothing (`send()` returns a resolved promise). +