From 5bc33eb8868836d1e471b6eb63c561402e6bc428 Mon Sep 17 00:00:00 2001 From: Ashok161 Date: Sat, 21 Mar 2026 13:27:56 +0530 Subject: [PATCH 1/2] feat: add configurable pre-chat form for collecting user info before conversation Implements #17. Adds a pre-chat form that appears before the chat starts, supporting text, email, select, and textarea fields with client-side validation. Form data is persisted per session and sent to the server as userData, which is injected into the system prompt for AI context. Made-with: Cursor --- .changeset/prechat-form.md | 6 + packages/server/src/config.ts | 1 + packages/server/src/handler.ts | 8 + packages/widget/src/api/types.ts | 1 + packages/widget/src/dom/input.ts | 4 + packages/widget/src/dom/messages.ts | 4 + packages/widget/src/dom/panel.ts | 16 +- packages/widget/src/dom/prechat-form.ts | 192 ++++++++++++++++++++++++ packages/widget/src/i18n.ts | 10 ++ packages/widget/src/storage.ts | 16 ++ packages/widget/src/styles/widget.css | 118 ++++++++++++++- packages/widget/src/widget.ts | 104 +++++++++++-- 12 files changed, 466 insertions(+), 14 deletions(-) create mode 100644 .changeset/prechat-form.md create mode 100644 packages/widget/src/dom/prechat-form.ts diff --git a/.changeset/prechat-form.md b/.changeset/prechat-form.md new file mode 100644 index 0000000..839d8de --- /dev/null +++ b/.changeset/prechat-form.md @@ -0,0 +1,6 @@ +--- +"@chatcops/widget": minor +"@chatcops/server": patch +--- + +Add configurable pre-chat form for collecting user info before conversation. Supports text, email, select, and textarea fields with validation. Form data is sent to the server and injected into the system prompt. diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 784417a..be91053 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -23,6 +23,7 @@ export const chatRequestSchema = z.object({ contentSnippet: z.string().max(2000).optional(), }).optional(), locale: z.string().max(10).optional(), + userData: z.record(z.string(), z.string().max(500)).optional(), }); export type ValidatedChatRequest = z.infer; diff --git a/packages/server/src/handler.ts b/packages/server/src/handler.ts index 14b7bf0..e07e260 100644 --- a/packages/server/src/handler.ts +++ b/packages/server/src/handler.ts @@ -84,6 +84,14 @@ export function createChatHandler(config: ChatCopsServerConfig) { } } + // Add user data from pre-chat form + if (req.userData && Object.keys(req.userData).length > 0) { + const userInfo = Object.entries(req.userData) + .map(([key, val]) => `${key}: ${val}`) + .join(', '); + systemPrompt += `\n\nUser information: ${userInfo}`; + } + // Add locale info if (req.locale) { systemPrompt += `\n\nRespond in the user's language. Current locale: ${req.locale}`; diff --git a/packages/widget/src/api/types.ts b/packages/widget/src/api/types.ts index 597ec63..ee36d34 100644 --- a/packages/widget/src/api/types.ts +++ b/packages/widget/src/api/types.ts @@ -8,6 +8,7 @@ export interface WidgetChatRequest { contentSnippet?: string; }; locale?: string; + userData?: Record; } export interface WidgetChatChunk { diff --git a/packages/widget/src/dom/input.ts b/packages/widget/src/dom/input.ts index 4ad5959..6bd8983 100644 --- a/packages/widget/src/dom/input.ts +++ b/packages/widget/src/dom/input.ts @@ -53,6 +53,10 @@ export class Input { this.autoResize(); } + setVisible(visible: boolean): void { + this.area.style.display = visible ? '' : 'none'; + } + private send(): void { const text = this.textarea.value.trim(); if (!text) return; diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index b8881b4..2af3e3e 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -56,6 +56,10 @@ export class Messages { this.typingEl = null; } + setVisible(visible: boolean): void { + this.container.style.display = visible ? '' : 'none'; + } + private scrollToBottom(): void { this.container.scrollTop = this.container.scrollHeight; } diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index fbd83fe..ee76e6e 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -23,6 +23,7 @@ export class Panel { private inline: boolean; messages: Messages; input: Input; + messagesContainer: HTMLElement; constructor(root: ShadowRoot, options: PanelOptions) { this.inline = options.inline ?? false; @@ -68,8 +69,11 @@ export class Panel { this.el.appendChild(header); - // Messages - this.messages = new Messages(this.el); + // Messages wrapper (shared container for messages and pre-chat form) + this.messagesContainer = document.createElement('div'); + this.messagesContainer.className = 'cc-messages-wrapper'; + this.el.appendChild(this.messagesContainer); + this.messages = new Messages(this.messagesContainer); // Input this.input = new Input(this.el, { @@ -105,6 +109,14 @@ export class Panel { return this.messages.addMessage(msg); } + hideMessages(): void { + this.messages.setVisible(false); + } + + showMessages(): void { + this.messages.setVisible(true); + } + destroy(): void { this.el.remove(); } diff --git a/packages/widget/src/dom/prechat-form.ts b/packages/widget/src/dom/prechat-form.ts new file mode 100644 index 0000000..c7e1fe3 --- /dev/null +++ b/packages/widget/src/dom/prechat-form.ts @@ -0,0 +1,192 @@ +import type { PreChatField } from '../widget.js'; +import type { WidgetLocaleStrings } from '../i18n.js'; + +export interface PreChatFormOptions { + title: string; + subtitle: string; + fields: PreChatField[]; + submitLabel: string; + locale: WidgetLocaleStrings; + onSubmit: (data: Record) => void; +} + +export class PreChatForm { + private container: HTMLDivElement; + private fieldElements = new Map(); + private errorElements = new Map(); + private submitBtn!: HTMLButtonElement; + private options: PreChatFormOptions; + + constructor(parent: HTMLElement, options: PreChatFormOptions) { + this.options = options; + this.container = document.createElement('div'); + this.container.className = 'cc-prechat'; + + if (options.title) { + const title = document.createElement('div'); + title.className = 'cc-prechat-title'; + title.textContent = options.title; + this.container.appendChild(title); + } + + if (options.subtitle) { + const subtitle = document.createElement('div'); + subtitle.className = 'cc-prechat-subtitle'; + subtitle.textContent = options.subtitle; + this.container.appendChild(subtitle); + } + + const form = document.createElement('form'); + form.className = 'cc-prechat-fields'; + form.addEventListener('submit', (e) => { + e.preventDefault(); + this.handleSubmit(); + }); + + for (const field of options.fields) { + form.appendChild(this.createField(field)); + } + + this.submitBtn = document.createElement('button'); + this.submitBtn.type = 'submit'; + this.submitBtn.className = 'cc-prechat-submit'; + this.submitBtn.textContent = options.submitLabel; + form.appendChild(this.submitBtn); + + this.container.appendChild(form); + parent.appendChild(this.container); + + this.updateSubmitState(); + + const firstInput = this.fieldElements.values().next().value; + if (firstInput) { + setTimeout(() => firstInput.focus(), 100); + } + } + + private createField(field: PreChatField): HTMLDivElement { + const wrapper = document.createElement('div'); + wrapper.className = 'cc-prechat-field'; + + const label = document.createElement('label'); + label.className = 'cc-prechat-label'; + label.textContent = field.label; + if (field.required) { + const req = document.createElement('span'); + req.className = 'cc-required'; + req.textContent = '*'; + label.appendChild(req); + } + wrapper.appendChild(label); + + let input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + + if (field.type === 'select') { + const select = document.createElement('select'); + select.className = 'cc-prechat-select'; + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = field.placeholder ?? `Select ${field.label.toLowerCase()}...`; + defaultOpt.disabled = true; + defaultOpt.selected = true; + select.appendChild(defaultOpt); + for (const opt of field.options ?? []) { + const option = document.createElement('option'); + option.value = opt; + option.textContent = opt; + select.appendChild(option); + } + input = select; + } else if (field.type === 'textarea') { + const textarea = document.createElement('textarea'); + textarea.className = 'cc-prechat-textarea'; + textarea.placeholder = field.placeholder ?? ''; + textarea.rows = 3; + input = textarea; + } else { + const textInput = document.createElement('input'); + textInput.type = field.type; + textInput.className = 'cc-prechat-input'; + textInput.placeholder = field.placeholder ?? ''; + input = textInput; + } + + input.name = field.name; + input.addEventListener('input', () => { + this.clearError(field.name); + this.updateSubmitState(); + }); + if (input instanceof HTMLSelectElement) { + input.addEventListener('change', () => { + this.clearError(field.name); + this.updateSubmitState(); + }); + } + wrapper.appendChild(input); + this.fieldElements.set(field.name, input); + + const error = document.createElement('div'); + error.className = 'cc-prechat-error'; + wrapper.appendChild(error); + this.errorElements.set(field.name, error); + + return wrapper; + } + + private updateSubmitState(): void { + const allRequiredFilled = this.options.fields.every((field) => { + if (!field.required) return true; + const el = this.fieldElements.get(field.name); + return el ? el.value.trim().length > 0 : false; + }); + this.submitBtn.disabled = !allRequiredFilled; + } + + private handleSubmit(): void { + const data: Record = {}; + let valid = true; + + for (const field of this.options.fields) { + const el = this.fieldElements.get(field.name); + if (!el) continue; + const value = el.value.trim(); + data[field.name] = value; + + if (field.required && !value) { + this.showError(field.name, this.options.locale.preChatRequired); + valid = false; + continue; + } + + if (field.type === 'email' && value && !this.isValidEmail(value)) { + this.showError(field.name, this.options.locale.preChatInvalidEmail); + valid = false; + continue; + } + } + + if (valid) { + this.options.onSubmit(data); + } + } + + private showError(fieldName: string, message: string): void { + const el = this.errorElements.get(fieldName); + if (el) el.textContent = message; + } + + private clearError(fieldName: string): void { + const el = this.errorElements.get(fieldName); + if (el) el.textContent = ''; + } + + private isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + } + + destroy(): void { + this.container.remove(); + this.fieldElements.clear(); + this.errorElements.clear(); + } +} diff --git a/packages/widget/src/i18n.ts b/packages/widget/src/i18n.ts index 935bc7c..6de4054 100644 --- a/packages/widget/src/i18n.ts +++ b/packages/widget/src/i18n.ts @@ -10,6 +10,11 @@ export interface WidgetLocaleStrings { poweredBy: string; newConversation: string; welcomeBubbleDefault: string; + preChatTitle: string; + preChatSubtitle: string; + preChatSubmit: string; + preChatRequired: string; + preChatInvalidEmail: string; } const en: WidgetLocaleStrings = { @@ -24,6 +29,11 @@ const en: WidgetLocaleStrings = { poweredBy: 'Powered by ChatCops', newConversation: 'New conversation', welcomeBubbleDefault: 'Need help? Chat with us!', + preChatTitle: 'Before we start...', + preChatSubtitle: 'Tell us a bit about yourself', + preChatSubmit: 'Start Chat', + preChatRequired: 'This field is required', + preChatInvalidEmail: 'Please enter a valid email', }; const locales: Record = { en }; diff --git a/packages/widget/src/storage.ts b/packages/widget/src/storage.ts index 224b71c..df0f2c6 100644 --- a/packages/widget/src/storage.ts +++ b/packages/widget/src/storage.ts @@ -57,4 +57,20 @@ export class ConversationStorage { return crypto.randomUUID(); } } + + isPreChatCompleted(sessionId: string): boolean { + try { + return sessionStorage.getItem(`chatcops-prechat-${sessionId}`) === 'true'; + } catch { + return false; + } + } + + setPreChatCompleted(sessionId: string): void { + try { + sessionStorage.setItem(`chatcops-prechat-${sessionId}`, 'true'); + } catch { + // Storage unavailable + } + } } diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index 7f54bb6..dcd37e5 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -226,7 +226,7 @@ /* Messages area */ .cc-messages { - flex: 1; + flex: 1 1 0; overflow-y: auto; padding: 16px; display: flex; @@ -469,6 +469,122 @@ color: var(--cc-text-secondary); } +/* Messages wrapper */ +.cc-messages-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +/* Pre-chat form */ +.cc-prechat { + flex: 1; + overflow-y: auto; + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.cc-prechat-title { + font-size: 18px; + font-weight: 600; + color: var(--cc-text); +} + +.cc-prechat-subtitle { + font-size: 13px; + color: var(--cc-text-secondary); + margin-top: -8px; +} + +.cc-prechat-fields { + display: flex; + flex-direction: column; + gap: 14px; +} + +.cc-prechat-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cc-prechat-label { + font-size: 13px; + font-weight: 500; + color: var(--cc-text); +} + +.cc-prechat-label .cc-required { + color: #ef4444; + margin-left: 2px; +} + +.cc-prechat-input, +.cc-prechat-select, +.cc-prechat-textarea { + background: var(--cc-bg-input); + border: 1px solid var(--cc-border); + border-radius: 8px; + padding: 8px 12px; + color: var(--cc-text); + font-family: var(--cc-font); + font-size: 14px; + outline: none; + transition: border-color 0.15s ease; +} + +.cc-prechat-input:focus, +.cc-prechat-select:focus, +.cc-prechat-textarea:focus { + border-color: var(--cc-accent); +} + +.cc-prechat-select { + appearance: auto; +} + +.cc-prechat-textarea { + resize: none; + line-height: 1.5; +} + +.cc-prechat-error { + font-size: 12px; + color: #ef4444; + min-height: 0; +} + +.cc-prechat-error:empty { + display: none; +} + +.cc-prechat-submit { + background: var(--cc-accent); + color: var(--cc-text); + border: none; + border-radius: 8px; + padding: 10px 16px; + font-family: var(--cc-font); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease; + margin-top: 8px; +} + +.cc-prechat-submit:hover { + background: var(--cc-accent-hover); +} + +.cc-prechat-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Mobile */ @media (max-width: 768px) { .cc-panel:not(.cc-inline) { diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 7e563b9..0e23b53 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -6,8 +6,26 @@ import { ChatClient } from './api/client.js'; import { ConversationStorage } from './storage.js'; import { applyTheme } from './theme.js'; import { getWidgetLocale, type WidgetLocaleStrings } from './i18n.js'; +import { PreChatForm } from './dom/prechat-form.js'; import type { MessageData } from './dom/messages.js'; +export interface PreChatField { + name: string; + type: 'text' | 'email' | 'select' | 'textarea'; + label: string; + placeholder?: string; + required?: boolean; + options?: string[]; +} + +export interface PreChatFormConfig { + enabled: boolean; + title?: string; + subtitle?: string; + fields: PreChatField[]; + submitLabel?: string; +} + export interface WidgetConfig { apiUrl: string; mode?: 'popup' | 'inline'; @@ -38,13 +56,15 @@ export interface WidgetConfig { autoOpen?: boolean | number; locale?: string; strings?: Partial; + preChatForm?: PreChatFormConfig; onOpen?: () => void; onClose?: () => void; onMessage?: (message: MessageData) => void; onError?: (error: Error) => void; + onPreChatSubmit?: (data: Record) => void; } -type WidgetEventType = 'open' | 'close' | 'message' | 'error'; +type WidgetEventType = 'open' | 'close' | 'message' | 'error' | 'preChatSubmit'; type WidgetEventHandler = (...args: unknown[]) => void; export class Widget { @@ -53,11 +73,13 @@ export class Widget { private fab?: FAB; private panel!: Panel; private bubble?: WelcomeBubble; + private preChatFormEl?: PreChatForm; private client: ChatClient; private storage: ConversationStorage; private locale: WidgetLocaleStrings; private conversationId: string; private messages: MessageData[] = []; + private userData?: Record; private isStreaming = false; private eventHandlers = new Map>(); private containerEl?: HTMLElement; @@ -136,16 +158,15 @@ export class Widget { } } - // Add welcome message if no history - if (this.messages.length === 0) { - const welcomeText = this.config.welcomeMessage ?? this.locale.welcomeMessage; - const welcomeMsg: MessageData = { - id: 'welcome', - role: 'assistant', - content: welcomeText, - }; - this.messages.push(welcomeMsg); - this.panel.addMessage(welcomeMsg); + // Show pre-chat form or messages + const shouldShowPreChat = this.config.preChatForm?.enabled + && !this.isPreChatCompleted() + && this.messages.length === 0; + + if (shouldShowPreChat) { + this.showPreChatForm(); + } else { + this.showChatView(); } // Welcome bubble — popup mode only @@ -200,6 +221,7 @@ export class Widget { } destroy(): void { + this.preChatFormEl?.destroy(); this.bubble?.destroy(); this.panel.destroy(); this.fab?.destroy(); @@ -223,6 +245,65 @@ export class Widget { this.eventHandlers.get(event)?.forEach((h) => h(...args)); } + private isPreChatCompleted(): boolean { + return this.storage.isPreChatCompleted(this.conversationId); + } + + private setPreChatCompleted(): void { + this.storage.setPreChatCompleted(this.conversationId); + } + + private showPreChatForm(): void { + const formConfig = this.config.preChatForm!; + this.panel.hideMessages(); + this.panel.input.setVisible(false); + + this.preChatFormEl = new PreChatForm(this.panel.messagesContainer, { + title: formConfig.title ?? this.locale.preChatTitle, + subtitle: formConfig.subtitle ?? this.locale.preChatSubtitle, + fields: formConfig.fields, + submitLabel: formConfig.submitLabel ?? this.locale.preChatSubmit, + locale: this.locale, + onSubmit: (data) => this.handlePreChatSubmit(data), + }); + } + + private handlePreChatSubmit(data: Record): void { + this.userData = data; + this.setPreChatCompleted(); + + this.preChatFormEl?.destroy(); + this.preChatFormEl = undefined; + + this.panel.showMessages(); + this.panel.input.setVisible(true); + + this.showChatView(); + + this.config.onPreChatSubmit?.(data); + this.emit('preChatSubmit', data); + + if (data.message) { + this.handleSend(data.message); + } + } + + private showChatView(): void { + if (this.messages.length === 0) { + const userName = this.userData?.name; + const welcomeText = userName + ? `Hi ${userName}! ${this.config.welcomeMessage ?? this.locale.welcomeMessage}` + : (this.config.welcomeMessage ?? this.locale.welcomeMessage); + const welcomeMsg: MessageData = { + id: 'welcome', + role: 'assistant', + content: welcomeText, + }; + this.messages.push(welcomeMsg); + this.panel.addMessage(welcomeMsg); + } + } + private async handleSend(text: string): Promise { if (this.isStreaming) return; @@ -257,6 +338,7 @@ export class Widget { message: text, pageContext, locale: this.config.locale, + userData: this.userData, })) { if (chunk.error) { const errorKey = chunk.error === 'rate_limit' ? 'errorRateLimit' From 428ac1176651be811c473e7f759c8a056fbef4e9 Mon Sep 17 00:00:00 2001 From: Ashok161 Date: Sat, 21 Mar 2026 17:36:28 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?conditional=20wrapper,=20send=20userData=20once,=20class-based?= =?UTF-8?q?=20visibility,=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/widget/dev.html | 12 + packages/widget/src/dom/input.ts | 2 +- packages/widget/src/dom/messages.ts | 2 +- packages/widget/src/dom/panel.ts | 12 +- packages/widget/src/styles/widget.css | 10 +- packages/widget/src/widget.ts | 9 +- .../widget/tests/dom/prechat-form.test.ts | 261 ++++++++++++++++++ 7 files changed, 298 insertions(+), 10 deletions(-) create mode 100644 packages/widget/tests/dom/prechat-form.test.ts diff --git a/packages/widget/dev.html b/packages/widget/dev.html index 40fb567..b793d87 100644 --- a/packages/widget/dev.html +++ b/packages/widget/dev.html @@ -64,6 +64,18 @@

Configuration

text: 'Need help? Try the chat widget!', delay: 2000, }, + preChatForm: { + enabled: true, + title: 'Before we start...', + subtitle: 'Tell us a bit about yourself', + fields: [ + { name: 'name', type: 'text', label: 'Name', placeholder: 'Your name', required: true }, + { name: 'email', type: 'email', label: 'Email', placeholder: 'you@example.com', required: true }, + { name: 'topic', type: 'select', label: 'Topic', options: ['Sales', 'Support', 'Billing', 'Other'] }, + { name: 'message', type: 'textarea', label: 'Message', placeholder: 'How can we help?' }, + ], + submitLabel: 'Start Chat', + }, }); // Log events diff --git a/packages/widget/src/dom/input.ts b/packages/widget/src/dom/input.ts index 6bd8983..cf19a48 100644 --- a/packages/widget/src/dom/input.ts +++ b/packages/widget/src/dom/input.ts @@ -54,7 +54,7 @@ export class Input { } setVisible(visible: boolean): void { - this.area.style.display = visible ? '' : 'none'; + this.area.classList.toggle('cc-hidden', !visible); } private send(): void { diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index 2af3e3e..81e25cc 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -57,7 +57,7 @@ export class Messages { } setVisible(visible: boolean): void { - this.container.style.display = visible ? '' : 'none'; + this.container.classList.toggle('cc-hidden', !visible); } private scrollToBottom(): void { diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index ee76e6e..7418a8b 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -7,6 +7,7 @@ const CLOSE_ICON = ` export interface PanelOptions { position: 'bottom-right' | 'bottom-left'; inline?: boolean; + preChatEnabled?: boolean; branding: { name: string; avatar?: string; @@ -69,10 +70,13 @@ export class Panel { this.el.appendChild(header); - // Messages wrapper (shared container for messages and pre-chat form) - this.messagesContainer = document.createElement('div'); - this.messagesContainer.className = 'cc-messages-wrapper'; - this.el.appendChild(this.messagesContainer); + if (options.preChatEnabled) { + this.messagesContainer = document.createElement('div'); + this.messagesContainer.className = 'cc-messages-wrapper'; + this.el.appendChild(this.messagesContainer); + } else { + this.messagesContainer = this.el; + } this.messages = new Messages(this.messagesContainer); // Input diff --git a/packages/widget/src/styles/widget.css b/packages/widget/src/styles/widget.css index dcd37e5..6131a2c 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -226,7 +226,7 @@ /* Messages area */ .cc-messages { - flex: 1 1 0; + flex: 1; overflow-y: auto; padding: 16px; display: flex; @@ -469,12 +469,16 @@ color: var(--cc-text-secondary); } -/* Messages wrapper */ +/* Visibility utility */ +.cc-hidden { + display: none; +} + +/* Messages wrapper (only present when pre-chat form is enabled) */ .cc-messages-wrapper { flex: 1; display: flex; flex-direction: column; - overflow: hidden; min-height: 0; } diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 0e23b53..5e90d2e 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -80,6 +80,7 @@ export class Widget { private conversationId: string; private messages: MessageData[] = []; private userData?: Record; + private userDataSent = false; private isStreaming = false; private eventHandlers = new Map>(); private containerEl?: HTMLElement; @@ -135,6 +136,7 @@ export class Widget { this.panel = new Panel(this.shadow, { position, inline: this.isInline, + preChatEnabled: this.config.preChatForm?.enabled ?? false, branding: { name: this.config.branding?.name ?? 'AI Assistant', avatar: this.config.branding?.avatar, @@ -331,6 +333,8 @@ export class Widget { ? { url: window.location.href, title: document.title } : undefined; + const includeUserData = this.userData && !this.userDataSent; + try { let firstChunk = true; for await (const chunk of this.client.sendMessage({ @@ -338,7 +342,7 @@ export class Widget { message: text, pageContext, locale: this.config.locale, - userData: this.userData, + ...(includeUserData ? { userData: this.userData } : {}), })) { if (chunk.error) { const errorKey = chunk.error === 'rate_limit' ? 'errorRateLimit' @@ -380,6 +384,9 @@ export class Widget { this.panel.addMessage(assistantMsg); this.config.onError?.(err instanceof Error ? err : new Error(String(err))); } finally { + if (includeUserData) { + this.userDataSent = true; + } this.isStreaming = false; this.panel.input.setDisabled(false); this.panel.input.focus(); diff --git a/packages/widget/tests/dom/prechat-form.test.ts b/packages/widget/tests/dom/prechat-form.test.ts new file mode 100644 index 0000000..8c78410 --- /dev/null +++ b/packages/widget/tests/dom/prechat-form.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PreChatForm } from '../../src/dom/prechat-form.js'; +import type { PreChatField } from '../../src/widget.js'; +import { getWidgetLocale } from '../../src/i18n.js'; + +const locale = getWidgetLocale('en'); + +function makeFields(overrides: Partial[] = []): PreChatField[] { + const defaults: PreChatField[] = [ + { name: 'name', type: 'text', label: 'Name', required: true, placeholder: 'Your name' }, + { name: 'email', type: 'email', label: 'Email', required: true, placeholder: 'you@example.com' }, + { name: 'topic', type: 'select', label: 'Topic', required: false, options: ['Sales', 'Support', 'Other'] }, + { name: 'message', type: 'textarea', label: 'Message', required: false, placeholder: 'How can we help?' }, + ]; + return overrides.length > 0 + ? overrides.map((o, i) => ({ ...defaults[i % defaults.length], ...o }) as PreChatField) + : defaults; +} + +function createForm( + parent: HTMLDivElement, + fieldOverrides: Partial[] = [], + onSubmit = vi.fn(), +) { + return new PreChatForm(parent, { + title: 'Before we start...', + subtitle: 'Tell us about yourself', + fields: makeFields(fieldOverrides), + submitLabel: 'Start Chat', + locale, + onSubmit, + }); +} + +describe('PreChatForm', () => { + let parent: HTMLDivElement; + + beforeEach(() => { + document.body.innerHTML = ''; + parent = document.createElement('div'); + document.body.appendChild(parent); + }); + + describe('field rendering', () => { + it('renders title and subtitle', () => { + createForm(parent); + expect(parent.querySelector('.cc-prechat-title')?.textContent).toBe('Before we start...'); + expect(parent.querySelector('.cc-prechat-subtitle')?.textContent).toBe('Tell us about yourself'); + }); + + it('renders all field types', () => { + createForm(parent); + expect(parent.querySelector('input[type="text"]')).toBeTruthy(); + expect(parent.querySelector('input[type="email"]')).toBeTruthy(); + expect(parent.querySelector('select')).toBeTruthy(); + expect(parent.querySelector('textarea')).toBeTruthy(); + }); + + it('renders required markers on required fields', () => { + createForm(parent); + const labels = parent.querySelectorAll('.cc-prechat-label'); + const nameLabel = labels[0]; + const emailLabel = labels[1]; + const topicLabel = labels[2]; + + expect(nameLabel.querySelector('.cc-required')).toBeTruthy(); + expect(emailLabel.querySelector('.cc-required')).toBeTruthy(); + expect(topicLabel.querySelector('.cc-required')).toBeNull(); + }); + + it('renders select options including disabled placeholder', () => { + createForm(parent); + const select = parent.querySelector('select') as HTMLSelectElement; + const options = select.querySelectorAll('option'); + expect(options.length).toBe(4); // placeholder + 3 options + expect(options[0].disabled).toBe(true); + expect(options[0].selected).toBe(true); + expect(options[1].textContent).toBe('Sales'); + }); + + it('renders submit button', () => { + createForm(parent); + const btn = parent.querySelector('.cc-prechat-submit') as HTMLButtonElement; + expect(btn).toBeTruthy(); + expect(btn.textContent).toBe('Start Chat'); + }); + }); + + describe('required field validation', () => { + it('shows error for empty required fields on submit', () => { + const onSubmit = vi.fn(); + createForm(parent, [], onSubmit); + + const btn = parent.querySelector('.cc-prechat-submit') as HTMLButtonElement; + const form = parent.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit', { cancelable: true })); + + const errors = parent.querySelectorAll('.cc-prechat-error'); + const nameError = errors[0]; + expect(nameError.textContent).toBe('This field is required'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('clears error on input', () => { + createForm(parent); + + const form = parent.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit', { cancelable: true })); + + const nameInput = parent.querySelector('input[name="name"]') as HTMLInputElement; + const nameError = parent.querySelectorAll('.cc-prechat-error')[0]; + expect(nameError.textContent).toBe('This field is required'); + + nameInput.value = 'Alice'; + nameInput.dispatchEvent(new Event('input')); + expect(nameError.textContent).toBe(''); + }); + + it('disables submit when required fields are empty', () => { + createForm(parent); + const btn = parent.querySelector('.cc-prechat-submit') as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); + + it('enables submit when all required fields are filled', () => { + createForm(parent); + + const nameInput = parent.querySelector('input[name="name"]') as HTMLInputElement; + const emailInput = parent.querySelector('input[name="email"]') as HTMLInputElement; + + nameInput.value = 'Alice'; + nameInput.dispatchEvent(new Event('input')); + expect((parent.querySelector('.cc-prechat-submit') as HTMLButtonElement).disabled).toBe(true); + + emailInput.value = 'alice@example.com'; + emailInput.dispatchEvent(new Event('input')); + expect((parent.querySelector('.cc-prechat-submit') as HTMLButtonElement).disabled).toBe(false); + }); + }); + + describe('email validation', () => { + it('shows error for invalid email on submit', () => { + const onSubmit = vi.fn(); + createForm(parent, [], onSubmit); + + const nameInput = parent.querySelector('input[name="name"]') as HTMLInputElement; + const emailInput = parent.querySelector('input[name="email"]') as HTMLInputElement; + + nameInput.value = 'Alice'; + nameInput.dispatchEvent(new Event('input')); + emailInput.value = 'not-an-email'; + emailInput.dispatchEvent(new Event('input')); + + const form = parent.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit', { cancelable: true })); + + const errors = parent.querySelectorAll('.cc-prechat-error'); + const emailError = errors[1]; + expect(emailError.textContent).toBe('Please enter a valid email'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('accepts valid email format', () => { + const onSubmit = vi.fn(); + createForm(parent, [], onSubmit); + + const nameInput = parent.querySelector('input[name="name"]') as HTMLInputElement; + const emailInput = parent.querySelector('input[name="email"]') as HTMLInputElement; + + nameInput.value = 'Alice'; + nameInput.dispatchEvent(new Event('input')); + emailInput.value = 'alice@example.com'; + emailInput.dispatchEvent(new Event('input')); + + const form = parent.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit', { cancelable: true })); + + expect(onSubmit).toHaveBeenCalledOnce(); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Alice', + email: 'alice@example.com', + })); + }); + }); + + describe('form submission', () => { + it('calls onSubmit with all field values', () => { + const onSubmit = vi.fn(); + createForm(parent, [], onSubmit); + + const nameInput = parent.querySelector('input[name="name"]') as HTMLInputElement; + const emailInput = parent.querySelector('input[name="email"]') as HTMLInputElement; + const select = parent.querySelector('select[name="topic"]') as HTMLSelectElement; + const textarea = parent.querySelector('textarea[name="message"]') as HTMLTextAreaElement; + + nameInput.value = 'Alice'; + nameInput.dispatchEvent(new Event('input')); + emailInput.value = 'alice@example.com'; + emailInput.dispatchEvent(new Event('input')); + select.value = 'Support'; + select.dispatchEvent(new Event('change')); + textarea.value = 'Need help with billing'; + textarea.dispatchEvent(new Event('input')); + + const form = parent.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit', { cancelable: true })); + + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Alice', + email: 'alice@example.com', + topic: 'Support', + message: 'Need help with billing', + }); + }); + }); + + describe('show/skip logic', () => { + it('skips title when empty string', () => { + new PreChatForm(parent, { + title: '', + subtitle: '', + fields: makeFields(), + submitLabel: 'Go', + locale, + onSubmit: vi.fn(), + }); + expect(parent.querySelector('.cc-prechat-title')).toBeNull(); + expect(parent.querySelector('.cc-prechat-subtitle')).toBeNull(); + }); + + it('renders form with only optional fields (submit enabled by default)', () => { + const onSubmit = vi.fn(); + new PreChatForm(parent, { + title: 'Optional form', + subtitle: '', + fields: [ + { name: 'note', type: 'text', label: 'Note', required: false }, + ], + submitLabel: 'Continue', + locale, + onSubmit, + }); + + const btn = parent.querySelector('.cc-prechat-submit') as HTMLButtonElement; + expect(btn.disabled).toBe(false); + + const form = parent.querySelector('form') as HTMLFormElement; + form.dispatchEvent(new Event('submit', { cancelable: true })); + expect(onSubmit).toHaveBeenCalledWith({ note: '' }); + }); + }); + + describe('destroy', () => { + it('removes the form from DOM', () => { + const form = createForm(parent); + expect(parent.querySelector('.cc-prechat')).toBeTruthy(); + form.destroy(); + expect(parent.querySelector('.cc-prechat')).toBeNull(); + }); + }); +});