From 061bafb1d7d0f3739615da71e55a3b887ea20e65 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Fri, 30 Oct 2026 02:07:09 +0530 Subject: [PATCH 1/2] feat(widget): wait for cookie consent before using storage (#405) `waitForConsent` (and `data-wait-for-consent`) holds the chat behind a notice until `ChatCops.setConsent(true)`. While it waits the widget reads and writes no localStorage or sessionStorage and calls no API, and the welcome bubble stays hidden. The notice has a button that calls `onConsentRequest`, left out when there is none. `setConsent(true)` shows the normal chat (the saved conversation comes back); `setConsent(false)` closes the chat and removes the conversation, the session token and the bubble's flag. Both work before init() and before the chat has loaded, through the loader and the Widget class. Panel.hideMessages/showMessages become one setBodyVisible(), and the new private members join the mangle list, to keep the panel in budget. The single-file limit goes from 19 KB to 19.5 KB. --- .changeset/widget-consent-gate.md | 5 + api/widget.api.md | 5 + e2e/fixtures/consent.html | 21 + e2e/server.mjs | 1 + e2e/tests/loader.spec.ts | 30 ++ e2e/tests/states.spec.ts | 18 +- packages/widget/mangle.ts | 2 +- packages/widget/src/api/client.ts | 11 +- packages/widget/src/dom/bubble.ts | 2 +- packages/widget/src/dom/panel.ts | 10 +- packages/widget/src/i18n.ts | 4 + packages/widget/src/loader/loader.ts | 14 + packages/widget/src/script-config.ts | 1 + packages/widget/src/styles/widget.css | 14 + packages/widget/src/widget.ts | 117 ++++- packages/widget/tests/consent.test.ts | 467 ++++++++++++++++++ website/src/content/docs/widget/api.mdx | 8 + .../src/content/docs/widget/configuration.mdx | 26 + website/src/content/docs/widget/i18n.mdx | 2 + 19 files changed, 724 insertions(+), 34 deletions(-) create mode 100644 .changeset/widget-consent-gate.md create mode 100644 e2e/fixtures/consent.html create mode 100644 packages/widget/tests/consent.test.ts diff --git a/.changeset/widget-consent-gate.md b/.changeset/widget-consent-gate.md new file mode 100644 index 00000000..7b58e974 --- /dev/null +++ b/.changeset/widget-consent-gate.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": minor +--- + +`waitForConsent` holds the chat behind a notice until `ChatCops.setConsent(true)`. While it waits the widget uses no storage and calls no API; `setConsent(false)` closes the chat and removes what it saved. The notice has a button that calls `onConsentRequest`, and the script tag takes `data-wait-for-consent`. diff --git a/api/widget.api.md b/api/widget.api.md index 5567b20b..ab7b4e07 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -87,6 +87,7 @@ export declare class Widget { get isInline(): boolean; constructor(config: WidgetConfig); init(): void; + setConsent(consent: boolean): void; open(): void; close(): void; toggle(): void; @@ -148,6 +149,8 @@ export interface WidgetConfig { onError?: (error: Error) => void; onLeadCaptured?: (leadData?: Record) => void; onPreChatSubmit?: (data: Record) => void; + waitForConsent?: boolean; + onConsentRequest?: () => void; } export type WidgetEventHandler = (...args: WidgetEvents[E]) => void; @@ -208,6 +211,8 @@ export interface WidgetLocaleStrings { sourcesLabel: string; disclosure: string; disclosureLearnMore: string; + consentNotice: string; + consentButton: string; } export type WidgetUpdate = Pick & { diff --git a/e2e/fixtures/consent.html b/e2e/fixtures/consent.html new file mode 100644 index 00000000..784fb263 --- /dev/null +++ b/e2e/fixtures/consent.html @@ -0,0 +1,21 @@ + + + + + + ChatCops e2e host page, waiting for cookie consent + + +
+

Host page

+

The widget below waits until the visitor accepts functional cookies.

+ + +
+ + + + diff --git a/e2e/server.mjs b/e2e/server.mjs index b0698bd9..e00dca6b 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -27,6 +27,7 @@ const files = { '/full.html': ['fixtures/full.html', 'text/html'], '/theme.html': ['fixtures/theme.html', 'text/html'], '/prechat.html': ['fixtures/prechat.html', 'text/html'], + '/consent.html': ['fixtures/consent.html', 'text/html'], '/locale.html': ['fixtures/locale.html', 'text/html'], '/locales/zz.js': ['fixtures/locales/zz.js', 'text/javascript'], }; diff --git a/e2e/tests/loader.spec.ts b/e2e/tests/loader.spec.ts index 26a6984b..29219c82 100644 --- a/e2e/tests/loader.spec.ts +++ b/e2e/tests/loader.spec.ts @@ -40,6 +40,36 @@ for (const [build, path] of [['loader', '/'], ['single-file build', '/full.html' }); } +test('with data-wait-for-consent, opening the chat touches no storage and no API until setConsent(true)', async ({ page }) => { + await countStorageCalls(page); + const apiCalls: string[] = []; + page.on('request', (request) => new URL(request.url()).pathname === '/api/chat' && apiCalls.push(request.method())); + await page.goto('/consent.html'); + await page.locator(fab).click(); + await expect(page.locator('.cc-panel')).toHaveClass(/cc-visible/); + await expect(page.locator('.cc-consent')).toContainText('Chat is available once you accept functional cookies.'); + await expect(page.locator('.cc-message')).toHaveCount(0); + await page.waitForTimeout(300); + expect(await storageCalls(page)).toEqual([]); + expect(apiCalls).toEqual([]); + + await page.locator('#accept').click(); + await expect(page.locator('.cc-consent')).toHaveCount(0); + await expect(page.locator('.cc-message-assistant')).toHaveCount(1); + expect(await storageCalls(page)).toContain('getItem'); + + await page.locator('.cc-input').fill('Hi there'); + await page.locator('.cc-send-btn').click(); + await expect(page.locator('.cc-message-assistant').last()).toContainText('Hello from the test server.'); + expect(await page.evaluate(() => localStorage.getItem('chatcops_conversation'))).toContain('Hi there'); + + await page.locator('#reject').click(); + await expect(page.locator('.cc-panel')).not.toHaveClass(/cc-visible/); + expect(await page.evaluate(() => localStorage.getItem('chatcops_conversation'))).toBeNull(); + await page.locator(fab).click(); + await expect(page.locator('.cc-consent')).toBeVisible(); +}); + test('a conversation comes back after a reload, once the chat is opened', async ({ page }) => { await page.goto('/'); await page.locator(fab).click(); diff --git a/e2e/tests/states.spec.ts b/e2e/tests/states.spec.ts index b7bbbb7f..4eaba434 100644 --- a/e2e/tests/states.spec.ts +++ b/e2e/tests/states.spec.ts @@ -3,7 +3,7 @@ import AxeBuilder from '@axe-core/playwright'; // Every widget state in F-05's list that exists today, each checked with axe // against WCAG 2.2 AA in light, dark and right-to-left. States still to come: -// S8 tool confirmation (F-08), S12 handoff (F-10), S17 waiting for consent. +// S8 tool confirmation (F-08), S12 handoff (F-10). const fab = '.cc-fab'; const panel = '.cc-panel'; @@ -185,6 +185,22 @@ for (const look of looks) { await expectAccessible(page); }); + test('S17 waiting for consent', async ({ page }) => { + await start(page, look, { waitForConsent: true }); + // A function can't be passed in from the test, so set the widget up again with one. + await page.evaluate((config) => { + const chat = (window as unknown as { ChatCops: Api }).ChatCops; + chat.destroy(); + chat.init({ ...config, onConsentRequest: () => undefined }); + }, { apiUrl: '/api/chat', waitForConsent: true, ...look.config }); + await openChat(page); + await expect(page.locator('.cc-consent')).toBeVisible(); + await expect(page.locator('.cc-consent-btn')).toBeVisible(); + await page.locator('.cc-consent-btn').focus(); + await expect(page.locator('.cc-consent-btn')).toBeFocused(); + await expectAccessible(page); + }); + test.describe('S14 on a phone', () => { test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); diff --git a/packages/widget/mangle.ts b/packages/widget/mangle.ts index f71d336e..b689b22f 100644 --- a/packages/widget/mangle.ts +++ b/packages/widget/mangle.ts @@ -20,7 +20,7 @@ const EARLIER = [ * an object key, never on another object, never in a string. */ export const CHECKED = [ - 'announcement', 'autoResize', 'cleanupEmptyActionContainers', 'clearError', 'createActionButton', + 'announcement', 'autoResize', 'consentEl', 'consented', 'createBubble', 'showConsentNotice', 'cleanupEmptyActionContainers', 'clearError', 'createActionButton', 'createActionsContainer', 'createField', 'createMessageElement', 'createRequest', 'disclosureEl', 'endStream', 'ensureActionsContainer', 'errorElements', 'escapeHtml', 'fieldElements', 'findLatestRegeneratableAssistant', 'findMessage', 'findUserMessageForAssistant', 'getErrorMessage', 'handleKeydown', 'handlePreChatSubmit', diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index 0655e902..0a6d8987 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -49,11 +49,18 @@ export class ChatClient { * The signed-in visitor's identity token for the next session, or none. * The session token from before is dropped, so the next message gets one * for the new identity (the server starts a new conversation for a - * different user). + * different user). `forget: false` leaves the stored token alone, for a widget that + * may not use storage yet. */ - setUserToken(token: string | undefined): void { + setUserToken(token: string | undefined, forget = true): void { if (token === this.userToken) return; this.userToken = token; + if (forget) this.forgetSession(); + else this.saved = {}; + } + + /** Drops the session token, in memory and from the store. */ + forgetSession(): void { this.saved = {}; this.store.remove(this.sessionKey); } diff --git a/packages/widget/src/dom/bubble.ts b/packages/widget/src/dom/bubble.ts index 95675c97..153d66ca 100644 --- a/packages/widget/src/dom/bubble.ts +++ b/packages/widget/src/dom/bubble.ts @@ -11,7 +11,7 @@ export interface BubbleOptions { onClick: () => void; } -const BUBBLE_SHOWN_KEY = 'chatcops_bubble_shown'; +export const BUBBLE_SHOWN_KEY = 'chatcops_bubble_shown'; export class WelcomeBubble { private el: HTMLDivElement; diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index ae236a5c..2a980920 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -185,12 +185,10 @@ export class Panel { return this.messages.addMessage(msg); } - hideMessages(): void { - this.messages.setVisible(false); - } - - showMessages(): void { - this.messages.setVisible(true); + /** The conversation and the input together; the pre-chat form and the consent notice replace them. */ + setBodyVisible(visible: boolean): void { + this.messages.setVisible(visible); + this.input.setVisible(visible); } destroy(): void { diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 8c25b740..d0beab68 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -34,6 +34,8 @@ export interface WidgetLocaleStrings { sourcesLabel: string; disclosure: string; disclosureLearnMore: string; + consentNotice: string; + consentButton: string; } const en: WidgetLocaleStrings = { @@ -71,6 +73,8 @@ const en: WidgetLocaleStrings = { sourcesLabel: 'Sources', disclosure: "You're chatting with an AI assistant. It can make mistakes.", disclosureLearnMore: 'Learn more', + consentNotice: 'Chat is available once you accept functional cookies.', + consentButton: 'Cookie settings', }; const locales: Record = { en }; diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index fabcab1d..804d947d 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -36,6 +36,12 @@ export interface ChatCopsLoader { setContext(data: Record | null): void; /** Changes the theme, branding or wording of the chat. What you leave out stays as it is. Kept until the chat loads. */ update(changes: WidgetUpdate): void; + /** + * Tells the chat whether the visitor accepted functional cookies. Until `true`, a chat with + * `waitForConsent` shows a notice and never touches storage or the API. `false` closes the + * chat and removes what it saved. Works before `init()` and before the chat loads. + */ + setConsent(consent: boolean): void; /** The widget's version. */ readonly version: string; } @@ -103,6 +109,8 @@ export function createLoader( // so a signed-in page doesn't load the chat early. let user: Parameters[0] | undefined; let context: Record | null | undefined; + // setConsent() before the chat loads, or before init(): kept across init() like the user. + let consent: boolean | undefined; // update() calls before the chat loads, for this init() only. let updates: WidgetUpdate[] = []; const directionOf = (current: WidgetConfig) => current.dir ?? (current.locale ? registered.get(current.locale)?.dir : undefined); @@ -154,6 +162,7 @@ export function createLoader( // Swapped in one task, so the page never paints without a button. launcher?.remove(); launcher = button = undefined; + if (consent !== undefined) next.setConsent(consent); next.init(); if (user !== undefined) next.setUser(user); if (context !== undefined) next.setContext(context); @@ -267,6 +276,11 @@ export function createLoader( widget?.setContext(next); }, + setConsent(next) { + consent = next; + widget?.setConsent(next); + }, + update(changes) { if (widget) { widget.update(changes); diff --git a/packages/widget/src/script-config.ts b/packages/widget/src/script-config.ts index 55f0c08e..0409d013 100644 --- a/packages/widget/src/script-config.ts +++ b/packages/widget/src/script-config.ts @@ -54,5 +54,6 @@ export function configFromScript(script: HTMLScriptElement): WidgetConfig | unde dir: (['ltr', 'rtl', 'auto'] as const).find((dir) => dir === script.dataset.dir), persistence: (['local', 'session', 'none'] as const).find((mode) => mode === script.dataset.persistence), historyTtlDays: /^\d+$/.test(script.dataset.historyTtlDays ?? '') ? Number(script.dataset.historyTtlDays) : undefined, + waitForConsent: script.dataset.waitForConsent === undefined ? undefined : script.dataset.waitForConsent !== 'false', }; } diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 3ac14532..58915869 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -218,6 +218,17 @@ text-decoration: underline; } +.cc-consent { + margin: auto; + padding: 24px; + text-align: center; + color: var(--cc-text-secondary); +} + +.cc-consent-btn { + margin-top: 12px; +} + .cc-message-error-note { margin-top: 6px; font-size: 12px; @@ -279,6 +290,7 @@ align-items: center; } +.cc-consent-btn, .cc-message-retry, .cc-message-regenerate { display: inline-flex; @@ -295,6 +307,7 @@ transition: background 0.15s, color 0.15s, opacity 0.15s; } +.cc-consent-btn:hover, .cc-message-retry:hover, .cc-message-regenerate:hover { background: var(--cc-bg-input); @@ -578,6 +591,7 @@ /* Keyboard focus indicators (the launcher's are in launcher.css) */ .cc-header-close:focus-visible, +.cc-consent-btn:focus-visible, .cc-send-btn:focus-visible, .cc-suggestion:focus-visible, .cc-message-retry:focus-visible, diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 5c61eea2..cb13c4c9 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -1,7 +1,7 @@ import { createShadowRoot, destroyShadowRoot } from './dom/shadow.js'; import { FAB } from './dom/fab.js'; import { Panel, type PanelLabels } from './dom/panel.js'; -import { WelcomeBubble } from './dom/bubble.js'; +import { BUBBLE_SHOWN_KEY, WelcomeBubble } from './dom/bubble.js'; import { ChatClient } from './api/client.js'; import { ConversationStorage, type Persistence } from './storage.js'; import { claimNamespace, type ClaimedNamespace } from './namespace.js'; @@ -103,6 +103,14 @@ export interface WidgetConfig { onError?: (error: Error) => void; onLeadCaptured?: (leadData?: Record) => void; onPreChatSubmit?: (data: Record) => void; + /** + * Holds the chat until `setConsent(true)`: it shows a notice instead of the + * conversation, and reads and writes no storage and calls no API meanwhile. + * Default false. + */ + waitForConsent?: boolean; + /** Called by the notice's button, to open the site's own consent banner. Without it the button is left out. */ + onConsentRequest?: () => void; } /** What `update()` can change on a running chat. */ @@ -188,6 +196,9 @@ export class Widget { private autoOpenTimer?: ReturnType; private unreadReplies = 0; private savedOverflow?: { value: string; priority: string }; + /** The visitor accepted functional cookies, or the site doesn't wait for them. */ + private consented: boolean; + private consentEl?: HTMLElement; get isInline(): boolean { return this.config.mode === 'inline'; @@ -205,8 +216,9 @@ export class Widget { ); const persistence = config.persistence ?? 'local'; this.storage = new ConversationStorage(this.namespace.siteId, persistence, config.historyTtlDays); - this.client = new ChatClient({ ...config, store: this.storage.store }); this.locale = getWidgetLocale(config.locale ?? 'en', config.strings); + this.consented = !config.waitForConsent; + this.client = new ChatClient({ ...config, store: this.storage.store }); } private resolveContainer(): HTMLElement { @@ -279,18 +291,7 @@ export class Widget { // conversation when it's first opened, so nothing touches storage before. if (this.isInline) this.hydrate(); - // Welcome bubble — popup mode only - if (!this.isInline && this.config.welcomeBubble) { - this.bubble = new WelcomeBubble(this.shadow, { - text: this.config.welcomeBubble.text, - delay: this.config.welcomeBubble.delay, - showOnce: this.config.welcomeBubble.showOnce ?? true, - store: this.storage.store, - position, - dismissLabel: this.locale.dismissBubble, - onClick: () => this.open(), - }); - } + if (this.consented) this.createBubble(); // Inline mode — show panel immediately if (this.isInline) { @@ -304,9 +305,78 @@ export class Widget { } } + /** The welcome bubble (popup mode), once storage may be used for its "shown" flag. */ + private createBubble(): void { + if (this.isInline || this.bubble || !this.config.welcomeBubble) return; + this.bubble = new WelcomeBubble(this.shadow, { + text: this.config.welcomeBubble.text, + delay: this.config.welcomeBubble.delay, + showOnce: this.config.welcomeBubble.showOnce ?? true, + store: this.storage.store, + position: this.config.theme?.position ?? 'bottom-right', + dismissLabel: this.locale.dismissBubble, + onClick: () => this.open(), + }); + } + + /** + * The visitor's answer on functional cookies. Until `true`, a widget with `waitForConsent` + * shows a notice and touches no storage or API. `false` closes the chat and removes what it + * saved (the conversation, the session token and the bubble's flag). + */ + setConsent(consent: boolean): void { + if (consent === this.consented) return; + // Not mounted yet: nothing has been read or saved, and init() follows the flag. + if (this.destroyed || !this.mounted) { + this.consented = consent; + return; + } + if (consent) { + this.consented = true; + this.consentEl?.remove(); + this.panel.setBodyVisible(true); + this.createBubble(); + if (this.isInline || this.panel.isVisible) this.hydrate(); + return; + } + // Cleared while the gate is still open; after this nothing is read or written. + this.reset(true, false); + this.hydrated = false; + this.storage.store.remove(BUBBLE_SHOWN_KEY); + this.client.forgetSession(); + this.consented = false; + this.bubble?.destroy(); + this.bubble = undefined; + if (this.isInline) this.hydrate(); + else if (this.panel.isVisible) this.close(); + } + + private showConsentNotice(): void { + if (this.consentEl?.isConnected) return; + this.panel.setBodyVisible(false); + const notice = document.createElement('div'); + notice.className = 'cc-consent'; + notice.textContent = this.locale.consentNotice; + const { onConsentRequest } = this.config; + if (onConsentRequest) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'cc-consent-btn'; + button.textContent = this.locale.consentButton; + button.addEventListener('click', () => onConsentRequest()); + notice.appendChild(button); + } + this.panel.messagesContainer.appendChild(notice); + this.consentEl = notice; + } + /** Reads the saved conversation and shows it, or the pre-chat form. Runs once. */ private hydrate(): void { if (this.hydrated) return; + if (!this.consented) { + this.showConsentNotice(); + return; + } this.hydrated = true; const saved = this.storage.load(); if (saved) { @@ -389,7 +459,9 @@ export class Widget { this.reset(true); } - private reset(forget: boolean): void { + private reset(forget: boolean, show = true): void { + // Waiting for consent: there is nothing to reset, and the notice stays up. + if (!this.consented) return; this.generation++; this.abortController?.abort(); this.abortController = undefined; @@ -408,6 +480,7 @@ export class Widget { this.preChatFormEl = undefined; this.panel.messages.clear(); this.panel.input.setDisabled(false); + if (!show) return; if (this.config.preChatForm?.enabled && !this.preChatDone) this.showPreChatForm(); else this.showChatView(); if (!forget) this.saveHistory(); @@ -424,7 +497,8 @@ export class Widget { const previous = this.client.identity; const token = user?.token; if (previous !== undefined && previous !== token) this.clear(); - this.client.setUserToken(token); + // While waiting, the stored session token is left alone: setUserToken() drops the one in memory. + this.client.setUserToken(token, this.consented); if (!user) return; const details = Object.fromEntries( Object.entries({ name: user.name, email: user.email }).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '') @@ -578,8 +652,7 @@ export class Widget { private showPreChatForm(): void { const formConfig = this.config.preChatForm!; - this.panel.hideMessages(); - this.panel.input.setVisible(false); + this.panel.setBodyVisible(false); loadPreChatForm().then( ({ PreChatForm }) => { @@ -599,8 +672,7 @@ export class Widget { console.error('[chatcops] The pre-chat form could not load:', error instanceof Error ? error.message : error); if (this.destroyed || this.preChatDone) return; this.preChatDone = true; - this.panel.showMessages(); - this.panel.input.setVisible(true); + this.panel.setBodyVisible(true); this.showChatView(); } ); @@ -616,8 +688,7 @@ export class Widget { this.preChatFormEl?.destroy(); this.preChatFormEl = undefined; - this.panel.showMessages(); - this.panel.input.setVisible(true); + this.panel.setBodyVisible(true); this.showChatView(); @@ -657,7 +728,7 @@ export class Widget { } private async handleSend(text: string): Promise { - if (this.isStreaming) return; + if (this.isStreaming || !this.consented) return; // A message sent before the chat was opened still joins the saved conversation. this.hydrate(); diff --git a/packages/widget/tests/consent.test.ts b/packages/widget/tests/consent.test.ts new file mode 100644 index 00000000..561a2ebc --- /dev/null +++ b/packages/widget/tests/consent.test.ts @@ -0,0 +1,467 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createLoader, type PanelModule } from '../src/loader/loader.js'; +import { configFromScript } from '../src/script-config.js'; +import { Widget, type WidgetConfig } from '../src/widget.js'; + +const API = 'https://api.test/chat'; +const CONVERSATION_KEY = 'chatcops_conversation'; +const SESSION_KEY = `chatcops-session:${API}`; +const BUBBLE_KEY = 'chatcops_bubble_shown'; + +type Internals = { shadow: ShadowRoot; messages: Array<{ id: string; content: string }> }; +const shadowOf = (widget: Widget) => (widget as unknown as Internals).shadow; +const messagesOf = (widget: Widget) => (widget as unknown as Internals).messages; +const noticeOf = (widget: Widget) => shadowOf(widget).querySelector('.cc-consent'); +const buttonOf = (widget: Widget) => shadowOf(widget).querySelector('.cc-consent-btn'); +const panelOf = (widget: Widget) => shadowOf(widget).querySelector('.cc-panel') as HTMLElement; +const inputOf = (widget: Widget) => shadowOf(widget).querySelector('.cc-input') as HTMLTextAreaElement; + +/** Wraps both storages so every call is counted (`get chatcops_x`, `set ...`, `remove ...`), then passes through. */ +function spyOnStorage() { + const calls: string[] = []; + const wrap = (target: Storage) => ({ + getItem(key: string) { + calls.push(`get ${key}`); + return target.getItem(key); + }, + setItem(key: string, value: string) { + calls.push(`set ${key}`); + target.setItem(key, value); + }, + removeItem(key: string) { + calls.push(`remove ${key}`); + target.removeItem(key); + }, + }); + const local = localStorage; + const session = sessionStorage; + vi.stubGlobal('localStorage', wrap(local)); + vi.stubGlobal('sessionStorage', wrap(session)); + return calls; +} + +const savedConversation = JSON.stringify({ + id: 'conv-1', + messages: [{ id: 'm1', role: 'user', content: 'Earlier question', timestamp: Date.now() }], + updatedAt: Date.now(), +}); + +let widget: Widget | undefined; +const start = (config: Partial = {}): Widget => { + const created = new Widget({ apiUrl: API, waitForConsent: true, ...config }); + created.init(); + widget = created; + return created; +}; + +beforeEach(() => { + document.body.innerHTML = ''; + localStorage.clear(); + sessionStorage.clear(); +}); + +afterEach(() => { + widget?.destroy(); + widget = undefined; + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('waiting for consent', () => { + it('touches no storage and calls no API while the chat is set up, opened and used', async () => { + const calls = spyOnStorage(); + const fetch = vi.spyOn(globalThis, 'fetch'); + const w = start({ welcomeBubble: { text: 'Need help?', delay: 0 }, autoOpen: true, persistence: 'session' }); + + w.open(); + await w.sendMessage('Hello'); + await w.sendMessage('Hello again', { open: false }); + w.newConversation(); + w.clear(); + w.setUser({ name: 'Ada', email: 'ada@example.com', token: 'jwt' }); + w.setUser(null); + w.setConsent(false); + w.close(); + + expect(calls).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('touches no storage in an inline chat, which is on screen from the start', () => { + const calls = spyOnStorage(); + const box = document.createElement('div'); + box.id = 'box'; + document.body.appendChild(box); + const w = start({ mode: 'inline', container: '#box' }); + + expect(calls).toEqual([]); + expect(noticeOf(w)?.textContent).toContain('Chat is available once you accept functional cookies.'); + }); + + it('does not show the welcome bubble', async () => { + vi.useFakeTimers(); + const calls = spyOnStorage(); + const w = start({ welcomeBubble: { text: 'Need help?', delay: 1000 } }); + await vi.advanceTimersByTimeAsync(5000); + + expect(shadowOf(w).querySelector('.cc-bubble')).toBeNull(); + expect(calls).toEqual([]); + }); + + it('shows the notice with a button instead of the conversation when the chat opens', () => { + const onConsentRequest = vi.fn(); + const w = start({ onConsentRequest }); + w.open(); + + expect(noticeOf(w)?.firstChild?.textContent).toBe('Chat is available once you accept functional cookies.'); + expect(buttonOf(w)?.textContent).toBe('Cookie settings'); + expect(messagesOf(w)).toEqual([]); + expect(shadowOf(w).querySelector('.cc-message')).toBeNull(); + expect(inputOf(w).closest('.cc-hidden')).not.toBeNull(); + }); + + it('calls onConsentRequest from the button', () => { + const onConsentRequest = vi.fn(); + const w = start({ onConsentRequest }); + w.open(); + expect(onConsentRequest).not.toHaveBeenCalled(); + + buttonOf(w)?.click(); + expect(onConsentRequest).toHaveBeenCalledTimes(1); + }); + + it('leaves the button out without onConsentRequest', () => { + const w = start(); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + expect(buttonOf(w)).toBeNull(); + }); + + it('uses the site’s own wording for the notice and the button', () => { + const w = start({ + onConsentRequest: () => undefined, + strings: { consentNotice: 'Accept cookies to chat.', consentButton: 'Open the banner' }, + }); + w.open(); + expect(noticeOf(w)?.textContent).toBe('Accept cookies to chat.Open the banner'); + }); + + it('shows the notice once however often the chat is opened', () => { + const w = start({ onConsentRequest: () => undefined }); + w.open(); + w.close(); + w.open(); + expect(shadowOf(w).querySelectorAll('.cc-consent')).toHaveLength(1); + }); + + it('puts the notice in the tab order', () => { + const w = start({ onConsentRequest: () => undefined }); + w.open(); + expect(buttonOf(w)?.tabIndex).toBe(0); + expect(buttonOf(w)?.disabled).toBe(false); + }); + + it('leaves a site without waitForConsent as it was', () => { + localStorage.setItem(CONVERSATION_KEY, savedConversation); + const w = start({ waitForConsent: undefined }); + w.open(); + + expect(noticeOf(w)).toBeNull(); + expect(messagesOf(w).some((m) => m.content === 'Earlier question')).toBe(true); + }); +}); + +describe('setConsent(true)', () => { + it('turns the notice into the normal chat, with the saved conversation', () => { + localStorage.setItem(CONVERSATION_KEY, savedConversation); + const calls = spyOnStorage(); + const w = start({ onConsentRequest: () => undefined }); + w.open(); + expect(calls).toEqual([]); + + w.setConsent(true); + expect(noticeOf(w)).toBeNull(); + expect(calls).toContain(`get ${CONVERSATION_KEY}`); + expect(messagesOf(w).some((m) => m.content === 'Earlier question')).toBe(true); + expect(panelOf(w).classList.contains('cc-visible')).toBe(true); + }); + + it('shows the welcome message when nothing was saved', () => { + const w = start(); + w.open(); + w.setConsent(true); + expect(messagesOf(w).map((m) => m.id)).toEqual(['welcome']); + }); + + it('does not open a chat that is closed, and opens it normally afterwards', () => { + const calls = spyOnStorage(); + const w = start(); + w.setConsent(true); + + expect(panelOf(w).classList.contains('cc-visible')).toBe(false); + expect(calls).toEqual([]); + w.open(); + expect(calls).toContain(`get ${CONVERSATION_KEY}`); + expect(noticeOf(w)).toBeNull(); + }); + + it('lets a message be sent, and saves the conversation', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const w = start(); + w.open(); + w.setConsent(true); + + await w.sendMessage('Hello'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(CONVERSATION_KEY)).toContain('Hello'); + }); + + it('starts an inline chat that was waiting', () => { + localStorage.setItem(CONVERSATION_KEY, savedConversation); + const box = document.createElement('div'); + box.id = 'box'; + document.body.appendChild(box); + const w = start({ mode: 'inline', container: '#box' }); + expect(noticeOf(w)).not.toBeNull(); + + w.setConsent(true); + expect(noticeOf(w)).toBeNull(); + expect(messagesOf(w).some((m) => m.content === 'Earlier question')).toBe(true); + }); + + it('starts the welcome bubble that was held back', async () => { + vi.useFakeTimers(); + const w = start({ welcomeBubble: { text: 'Need help?', delay: 1000 } }); + await vi.advanceTimersByTimeAsync(2000); + w.setConsent(true); + await vi.advanceTimersByTimeAsync(1000); + + expect(shadowOf(w).querySelector('.cc-bubble')).not.toBeNull(); + expect(localStorage.getItem(BUBBLE_KEY)).toBe('1'); + }); + + it('works before init()', () => { + localStorage.setItem(CONVERSATION_KEY, savedConversation); + const created = new Widget({ apiUrl: API, waitForConsent: true }); + created.setConsent(true); + created.init(); + widget = created; + created.open(); + + expect(noticeOf(created)).toBeNull(); + expect(messagesOf(created).some((m) => m.content === 'Earlier question')).toBe(true); + }); + + it('is harmless when called twice, or when the site never waited', () => { + const calls = spyOnStorage(); + const w = start({ waitForConsent: false }); + w.setConsent(true); + w.setConsent(true); + expect(calls).toEqual([]); + }); +}); + +describe('setConsent(false)', () => { + it('closes the chat and removes the conversation, the session token and the bubble flag', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onClose = vi.fn(); + const w = start({ waitForConsent: false, onClose, welcomeBubble: { text: 'Hi', delay: 0 } }); + w.open(); + await w.sendMessage('Hello'); + localStorage.setItem(SESSION_KEY, JSON.stringify({ token: 'secret', conversationId: 'c' })); + localStorage.setItem(BUBBLE_KEY, '1'); + expect(localStorage.getItem(CONVERSATION_KEY)).toContain('Hello'); + + w.setConsent(false); + expect(panelOf(w).classList.contains('cc-visible')).toBe(false); + expect(onClose).toHaveBeenCalled(); + expect(localStorage.getItem(CONVERSATION_KEY)).toBeNull(); + expect(localStorage.getItem(SESSION_KEY)).toBeNull(); + expect(localStorage.getItem(BUBBLE_KEY)).toBeNull(); + expect(messagesOf(w)).toEqual([]); + }); + + it('removes what a session store kept, too', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const w = start({ waitForConsent: false, persistence: 'session' }); + await w.sendMessage('Hello'); + sessionStorage.setItem(SESSION_KEY, 'token'); + + w.setConsent(false); + expect(sessionStorage.getItem(CONVERSATION_KEY)).toBeNull(); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('shows the notice again on the next open, and then touches no storage', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const w = start({ waitForConsent: false, onConsentRequest: () => undefined }); + w.open(); + await w.sendMessage('Hello').catch(() => undefined); + w.setConsent(false); + + const calls = spyOnStorage(); + const fetch = vi.mocked(globalThis.fetch); + fetch.mockClear(); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + expect(buttonOf(w)).not.toBeNull(); + expect(shadowOf(w).querySelector('.cc-message')).toBeNull(); + await w.sendMessage('Still there?'); + expect(calls).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('can be given again: the chat comes back, and starts a new conversation', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const w = start({ waitForConsent: false }); + w.open(); + await w.sendMessage('Before'); + w.setConsent(false); + w.open(); + w.setConsent(true); + + expect(noticeOf(w)).toBeNull(); + expect(messagesOf(w).map((m) => m.id)).toEqual(['welcome']); + }); + + it('stops a reply that is still coming', async () => { + let signal: AbortSignal | undefined; + vi.spyOn(globalThis, 'fetch').mockImplementation((_url, options) => { + signal = options?.signal ?? undefined; + return new Promise(() => undefined); + }); + const w = start({ waitForConsent: false }); + void w.sendMessage('Hello'); + await Promise.resolve(); + expect(signal?.aborted).toBe(false); + + w.setConsent(false); + expect(signal?.aborted).toBe(true); + }); + + it('puts an inline chat back to the notice', () => { + const box = document.createElement('div'); + box.id = 'box'; + document.body.appendChild(box); + const w = start({ waitForConsent: false, mode: 'inline', container: '#box' }); + expect(noticeOf(w)).toBeNull(); + + w.setConsent(false); + expect(noticeOf(w)).not.toBeNull(); + expect(messagesOf(w)).toEqual([]); + }); + + it('makes a site that never set waitForConsent wait', () => { + const w = start({ waitForConsent: undefined }); + w.setConsent(false); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + }); +}); + +describe('the script tag loader', () => { + const widgetPanel = (): Promise => Promise.resolve({ Widget }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + const host = () => document.querySelector('#chatcops-root') as HTMLElement; + const noticeInHost = () => host()?.shadowRoot?.querySelector('.cc-consent') ?? null; + + it('waits for consent, then opens the normal chat when setConsent(true) comes after init()', async () => { + const calls = spyOnStorage(); + const loader = createLoader(widgetPanel); + loader.init({ apiUrl: API, waitForConsent: true, onConsentRequest: () => undefined }); + loader.open(); + await settle(); + expect(noticeInHost()).not.toBeNull(); + expect(calls).toEqual([]); + + loader.setConsent(true); + expect(noticeInHost()).toBeNull(); + expect(calls).toContain(`get ${CONVERSATION_KEY}`); + loader.destroy(); + }); + + it('keeps setConsent(true) from before init() and before the chat loads', async () => { + const loader = createLoader(widgetPanel); + loader.setConsent(true); + loader.init({ apiUrl: API, waitForConsent: true }); + loader.open(); + await settle(); + + expect(noticeInHost()).toBeNull(); + loader.destroy(); + }); + + it('keeps setConsent(true) from before the chat loads, when it comes after init()', async () => { + const loader = createLoader(widgetPanel); + loader.init({ apiUrl: API, waitForConsent: true }); + loader.setConsent(true); + loader.open(); + await settle(); + + expect(noticeInHost()).toBeNull(); + loader.destroy(); + }); + + it('uses the last answer when setConsent() is called more than once before the chat loads', async () => { + const loader = createLoader(widgetPanel); + loader.init({ apiUrl: API, waitForConsent: true }); + loader.setConsent(true); + loader.setConsent(false); + loader.open(); + await settle(); + + expect(noticeInHost()).not.toBeNull(); + loader.destroy(); + }); + + it('removes what the chat saved when consent is withdrawn', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const loader = createLoader(widgetPanel); + loader.init({ apiUrl: API, waitForConsent: true }); + loader.setConsent(true); + await loader.sendMessage('Hello'); + expect(localStorage.getItem(CONVERSATION_KEY)).toContain('Hello'); + + loader.setConsent(false); + expect(localStorage.getItem(CONVERSATION_KEY)).toBeNull(); + loader.destroy(); + }); + + it('touches no storage and does not call the API from setConsent(false) or the launcher while waiting', async () => { + const calls = spyOnStorage(); + const fetch = vi.spyOn(globalThis, 'fetch'); + const loader = createLoader(widgetPanel); + loader.init({ apiUrl: API, waitForConsent: true, welcomeBubble: { text: 'Hi', delay: 0 } }); + loader.setConsent(false); + await settle(); + await loader.sendMessage('Hello'); + + expect(calls).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + loader.destroy(); + }); +}); + +describe('data-wait-for-consent', () => { + const script = (data: Record) => { + const el = document.createElement('script'); + Object.assign(el.dataset, { apiUrl: API, ...data }); + return el; + }; + + it.each([ + [{ waitForConsent: '' }, true], + [{ waitForConsent: 'true' }, true], + [{ waitForConsent: 'false' }, false], + [{}, undefined], + ])('reads %j as %s', (data, expected) => { + expect(configFromScript(script(data))?.waitForConsent).toBe(expected); + }); +}); diff --git a/website/src/content/docs/widget/api.mdx b/website/src/content/docs/widget/api.mdx index 20d058b1..ea21e2ba 100644 --- a/website/src/content/docs/widget/api.mdx +++ b/website/src/content/docs/widget/api.mdx @@ -148,6 +148,14 @@ ChatCops.setUser({ name: session.name, email: session.email, token: session.chat ChatCops.setUser(null); ``` +### `ChatCops.setConsent(consent)` + +Tell the widget whether the visitor accepted functional cookies, for a chat with [`waitForConsent`](/widget/configuration/#waiting-for-cookie-consent). `true` replaces the notice with the normal chat; `false` closes the chat, removes the conversation and session token the widget saved in this browser, and shows the notice again on the next open. With the script tag build, a call before `init()` or before the chat has loaded is kept until it does, and the last call wins. + +```typescript +ChatCops.setConsent(true); +``` + ### `ChatCops.setContext(data)` Details the bot should know for the next messages, such as the visitor's plan or what's in their cart. They're sent with each message as page context: at most 20 fields, keys up to 40 characters, values up to 500. The bot sees them as details the page added, not as verified facts. `setContext(null)` removes them. Nothing is sent when `pageContext: false`. diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index b9100a9c..b127444f 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -164,6 +164,29 @@ The pre-chat form's answers are saved with the conversation, so they expire with `persistHistory` was removed in 1.0. A config that still sets it throws, so a site that asked for nothing to be saved never starts saving conversations. Use `persistence: 'none'` instead. +### Waiting for cookie consent + +A site under a strict consent policy can hold the chat back until the visitor accepts functional cookies. Set `waitForConsent` (or `data-wait-for-consent` on the script tag) and the launcher shows as usual, but opening the chat shows a notice instead of the conversation: "Chat is available once you accept functional cookies." While it waits, the widget reads and writes no `localStorage` or `sessionStorage`, not even to check, and it makes no request to your chat server. The welcome bubble stays hidden too, since it remembers that it was shown. + +```typescript +ChatCops.init({ + apiUrl: '/api/chat', + waitForConsent: true, + // The notice's button. Open your own consent banner here. Without it, the notice has no button. + onConsentRequest: () => consentBanner.open(), +}); + +// When the visitor accepts functional cookies: +ChatCops.setConsent(true); + +// If they withdraw it later: +ChatCops.setConsent(false); +``` + +`setConsent(true)` replaces the notice with the normal chat: the saved conversation comes back and the session starts. A chat the visitor has closed stays closed. It works before or after `init()`, and with the script tag build also before the chat has loaded. `setConsent(false)` closes the chat and removes what the widget saved in this browser (the conversation, the session token and the welcome bubble's flag), and the notice shows again on the next open. If the visitor never gave consent in this page view, `setConsent(false)` does nothing and touches no storage. + +The notice's wording is `consentNotice` and `consentButton` in the [locale](/widget/i18n/), and a language that doesn't have them yet shows them in English. + ### AI disclosure A new conversation starts with a short note: "You're chatting with an AI assistant. It can make mistakes." The EU AI Act (Article 50) asks that people know when they're talking to an AI, and the note stays at the top of the chat, also after "New conversation". Its strings are `disclosure` and `disclosureLearnMore` in the locale, and a language that doesn't have them yet shows them in English. @@ -253,6 +276,8 @@ interface WidgetConfig { placeholder?: string; // Input placeholder text persistence?: 'local' | 'session' | 'none'; // Where the chat is saved, default: 'local' historyTtlDays?: number; // Drop saved chats older than this, default: 30 + waitForConsent?: boolean; // Show a notice and use no storage or API until setConsent(true), default: false + onConsentRequest?: () => void; // The notice's button; leave it out to have no button maxMessages?: number; // Max messages to persist, default: 50 pageContext?: boolean; // Send page URL/title, default: true autoOpen?: boolean | number; // Popup mode only. true = immediate, number = delay in ms @@ -316,6 +341,7 @@ See the [pre-chat form guide](/widget/pre-chat-form) for field behavior, validat | `data-dir` | `dir` | Text direction: `'ltr'`, `'rtl'` or `'auto'` | | `data-persistence` | `persistence` | `'local'`, `'session'` or `'none'` | | `data-history-ttl-days` | `historyTtlDays` | Days a saved conversation is kept | +| `data-wait-for-consent` | `waitForConsent` | Wait for `ChatCops.setConsent(true)` before using storage or the API. `"false"` turns it off | | `data-turnstile-site-key` | `turnstileSiteKey` | Cloudflare Turnstile site key, when the server checks Turnstile | | `data-user-token` | `userToken` | Signed token for a signed-in visitor, when the server has `identity` set | diff --git a/website/src/content/docs/widget/i18n.mdx b/website/src/content/docs/widget/i18n.mdx index faa3d0f3..ca204ce4 100644 --- a/website/src/content/docs/widget/i18n.mdx +++ b/website/src/content/docs/widget/i18n.mdx @@ -105,6 +105,8 @@ interface LocaleStrings { unreadCount: string; disclosure: string; // "You're chatting with an AI assistant. It can make mistakes." disclosureLearnMore: string; // "Learn more" + consentNotice: string; // "Chat is available once you accept functional cookies." + consentButton: string; // "Cookie settings" } ``` From 3ad3af93a1113e44345d7e1a2672b8b65f19f2b2 Mon Sep 17 00:00:00 2001 From: Saif Ullah jafri Date: Fri, 30 Oct 2026 10:59:41 +0530 Subject: [PATCH 2/2] fix(widget): add setConsent to the npm and single-file ChatCops object, fix docs samples Review of #468: window.ChatCops and the default export had no setConsent, so those builds could not give consent. It now keeps the value until init() and applies it before init(), like the loader. Also declares consentBanner in the docs sample, notes that sendMessage() resolves without sending while consent is missing, and drops the .size-limit.json change (rebased on dev's new budgets). --- api/widget.api.md | 1 + packages/widget/src/index.ts | 13 +++ packages/widget/tests/api.test-d.ts | 7 ++ packages/widget/tests/consent.test.ts | 83 +++++++++++++++++++ website/src/content/docs/widget/api.mdx | 2 +- .../src/content/docs/widget/configuration.mdx | 4 +- 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/api/widget.api.md b/api/widget.api.md index ab7b4e07..9986d570 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -25,6 +25,7 @@ declare const _default: { token?: string; } | null): void; setContext(data: Record | null): void; + setConsent(value: boolean): void; update(changes: WidgetUpdate): void; destroy(): void; on(event: E, handler: WidgetEventHandler): () => void; diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index bc5dd847..19744062 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -27,6 +27,8 @@ export type { WidgetLocaleStrings } from './i18n.js'; export { parseAutoOpen } from './script-config.js'; let instance: Widget | null = null; +// Kept without an instance, so `setConsent()` can come before `init()`; like the loader's, it outlives `destroy()`. +let consent: boolean | undefined; const persistentHandlers = new Map>>(); const ChatCops = { @@ -40,6 +42,7 @@ const ChatCops = { instance.on(event, handler as WidgetEventHandler); } } + if (consent !== undefined) instance.setConsent(consent); instance.init(); return instance; }, @@ -93,6 +96,16 @@ const ChatCops = { instance?.setContext(data); }, + /** + * Whether the visitor accepted functional cookies, for a chat with `waitForConsent`. `true` shows the chat; + * `false` closes it and removes what it saved in this browser. May come before `init()`; the last call wins. + * While consent is missing `sendMessage()` resolves without sending, so don't wait on a reply. + */ + setConsent(value: boolean): void { + consent = value; + instance?.setConsent(value); + }, + /** Changes the theme, branding or wording of the running chat. What you leave out stays as it is. */ update(changes: WidgetUpdate): void { instance?.update(changes); diff --git a/packages/widget/tests/api.test-d.ts b/packages/widget/tests/api.test-d.ts index f82aa205..6ea3b126 100644 --- a/packages/widget/tests/api.test-d.ts +++ b/packages/widget/tests/api.test-d.ts @@ -34,6 +34,13 @@ describe('the widget API types', () => { ChatCops.setContext({ seats: 12 }); }); + it('types setConsent the same in both builds', () => { + expectTypeOf(ChatCops.setConsent).parameters.toEqualTypeOf<[boolean]>(); + expectTypeOf().parameters.toEqualTypeOf<[boolean]>(); + // @ts-expect-error: consent is a boolean + ChatCops.setConsent('yes'); + }); + it('types update() the same in both builds, without position', () => { expectTypeOf(ChatCops.update).parameters.toEqualTypeOf<[WidgetUpdate]>(); expectTypeOf().parameters.toEqualTypeOf<[WidgetUpdate]>(); diff --git a/packages/widget/tests/consent.test.ts b/packages/widget/tests/consent.test.ts index 561a2ebc..8b248c05 100644 --- a/packages/widget/tests/consent.test.ts +++ b/packages/widget/tests/consent.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLoader, type PanelModule } from '../src/loader/loader.js'; import { configFromScript } from '../src/script-config.js'; +import ChatCops from '../src/index.js'; import { Widget, type WidgetConfig } from '../src/widget.js'; const API = 'https://api.test/chat'; @@ -465,3 +466,85 @@ describe('data-wait-for-consent', () => { expect(configFromScript(script(data))?.waitForConsent).toBe(expected); }); }); + +describe('ChatCops.setConsent (npm build and single file)', () => { + const init = (config: Partial = {}): Widget => { + widget = ChatCops.init({ apiUrl: API, waitForConsent: true, ...config }); + return widget; + }; + + afterEach(() => { + ChatCops.destroy(); + ChatCops.setConsent(false); + }); + + it('does nothing without a chat, and does not throw', () => { + ChatCops.destroy(); + expect(() => ChatCops.setConsent(true)).not.toThrow(); + }); + + it('accepts consent after init(), replacing the notice', () => { + ChatCops.setConsent(false); + const w = init(); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + + ChatCops.setConsent(true); + expect(noticeOf(w)).toBeNull(); + expect(messagesOf(w).map((m) => m.id)).toEqual(['welcome']); + }); + + it('accepts consent before init(), so the chat starts without the notice', () => { + ChatCops.setConsent(true); + const w = init(); + w.open(); + expect(noticeOf(w)).toBeNull(); + expect(messagesOf(w).map((m) => m.id)).toEqual(['welcome']); + }); + + it('keeps a refusal before init(), and touches no storage while waiting', () => { + const calls = spyOnStorage(); + ChatCops.setConsent(false); + const w = init(); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + expect(calls).toEqual([]); + }); + + it('uses the last call made before init()', () => { + ChatCops.setConsent(true); + ChatCops.setConsent(false); + const w = init(); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + }); + + it('keeps the answer through destroy() and a second init()', () => { + ChatCops.setConsent(true); + init(); + ChatCops.destroy(); + const w = init(); + w.open(); + expect(noticeOf(w)).toBeNull(); + }); + + it('withdraws consent on a running chat: clears what it saved', () => { + ChatCops.setConsent(true); + const w = init(); + w.open(); + localStorage.setItem(CONVERSATION_KEY, savedConversation); + ChatCops.setConsent(false); + expect(localStorage.getItem(CONVERSATION_KEY)).toBeNull(); + w.open(); + expect(noticeOf(w)).not.toBeNull(); + }); + + it('resolves sendMessage() without sending while consent is missing', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + ChatCops.setConsent(false); + init(); + await expect(ChatCops.sendMessage('hello')).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/website/src/content/docs/widget/api.mdx b/website/src/content/docs/widget/api.mdx index ea21e2ba..20c1cdce 100644 --- a/website/src/content/docs/widget/api.mdx +++ b/website/src/content/docs/widget/api.mdx @@ -150,7 +150,7 @@ ChatCops.setUser(null); ### `ChatCops.setConsent(consent)` -Tell the widget whether the visitor accepted functional cookies, for a chat with [`waitForConsent`](/widget/configuration/#waiting-for-cookie-consent). `true` replaces the notice with the normal chat; `false` closes the chat, removes the conversation and session token the widget saved in this browser, and shows the notice again on the next open. With the script tag build, a call before `init()` or before the chat has loaded is kept until it does, and the last call wins. +Tell the widget whether the visitor accepted functional cookies, for a chat with [`waitForConsent`](/widget/configuration/#waiting-for-cookie-consent). `true` replaces the notice with the normal chat; `false` closes the chat, removes the conversation and session token the widget saved in this browser, and shows the notice again on the next open. With the script tag build, a call before `init()` or before the chat has loaded is kept until it does, and the last call wins. While consent is missing, `sendMessage()` resolves without sending, so don't wait on a reply. ```typescript ChatCops.setConsent(true); diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index b127444f..ef0ea6b7 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -169,6 +169,8 @@ The pre-chat form's answers are saved with the conversation, so they expire with A site under a strict consent policy can hold the chat back until the visitor accepts functional cookies. Set `waitForConsent` (or `data-wait-for-consent` on the script tag) and the launcher shows as usual, but opening the chat shows a notice instead of the conversation: "Chat is available once you accept functional cookies." While it waits, the widget reads and writes no `localStorage` or `sessionStorage`, not even to check, and it makes no request to your chat server. The welcome bubble stays hidden too, since it remembers that it was shown. ```typescript +declare const consentBanner: { open(): void }; + ChatCops.init({ apiUrl: '/api/chat', waitForConsent: true, @@ -183,7 +185,7 @@ ChatCops.setConsent(true); ChatCops.setConsent(false); ``` -`setConsent(true)` replaces the notice with the normal chat: the saved conversation comes back and the session starts. A chat the visitor has closed stays closed. It works before or after `init()`, and with the script tag build also before the chat has loaded. `setConsent(false)` closes the chat and removes what the widget saved in this browser (the conversation, the session token and the welcome bubble's flag), and the notice shows again on the next open. If the visitor never gave consent in this page view, `setConsent(false)` does nothing and touches no storage. +`setConsent(true)` replaces the notice with the normal chat: the saved conversation comes back and the session starts. A chat the visitor has closed stays closed. It works before or after `init()`, and with the script tag build also before the chat has loaded. `setConsent(false)` closes the chat and removes what the widget saved in this browser (the conversation, the session token and the welcome bubble's flag), and the notice shows again on the next open. If the visitor never gave consent in this page view, `setConsent(false)` does nothing and touches no storage. While consent is missing, `sendMessage()` resolves without sending, so don't wait on a reply. The notice's wording is `consentNotice` and `consentButton` in the [locale](/widget/i18n/), and a language that doesn't have them yet shows them in English.