Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/react-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@chatcops/react": minor
---

Add `@chatcops/react` with `<ChatCops>` component and `useChatCops()` hook for React and Next.js applications.

6 changes: 6 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
97 changes: 97 additions & 0 deletions api/react.api.md
Original file line number Diff line number Diff line change
@@ -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<void>;
update: (changes: WidgetUpdate) => void;
}

export type ChatCopsProps = WidgetConfig & {
onClose?: () => void;
onError?: (error: Error) => void;
onLeadCaptured?: (lead?: Record<string, unknown>) => void;
onMessage?: (message: MessageData) => void;
onOpen?: () => void;
onPreChatSubmit?: (data: Record<string, string>) => 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<WidgetLocaleStrings>;
preChatForm?: PreChatFormConfig;
onOpen?: () => void;
onClose?: () => void;
onMessage?: (message: MessageData) => void;
onError?: (error: Error) => void;
onLeadCaptured?: (leadData?: Record<string, unknown>) => void;
onPreChatSubmit?: (data: Record<string, string>) => void;
waitForConsent?: boolean;
onConsentRequest?: () => void;
privacyUrl?: string;
}

export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' | 'privacyUrl'> & {
theme?: Omit<ThemeConfig, 'position'>;
};
```
58 changes: 58 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}

135 changes: 135 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void;
onPreChatSubmit?: (data: Record<string, string>) => void;
};

export interface ChatCopsHandle {
open: () => void;
close: () => void;
toggle: () => void;
send: (text: string, options?: { open?: boolean }) => Promise<void>;
update: (changes: WidgetUpdate) => void;
}

type WidgetModule = typeof import('@chatcops/widget').default;

let W: WidgetModule | undefined, P: Promise<WidgetModule> | undefined;

const load = async (): Promise<WidgetModule> => {
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<string, unknown>;
for (const k of EK) delete c[k];
return c as unknown as WidgetConfig;
};

const diff = (
a: Record<string, unknown>,
b: Record<string, unknown>,
): { reinit?: boolean; update?: WidgetUpdate } => {
if ((a.theme as Record<string, unknown> | undefined)?.position !== (b.theme as Record<string, unknown> | undefined)?.position) {
return { reinit: true };
}
const u: Record<string, unknown> = {};
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<string, ((...a: unknown[]) => 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<string, unknown>,
props as unknown as Record<string, unknown>,
);
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 };
Loading