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/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/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..cf19a48 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.classList.toggle('cc-hidden', !visible); + } + 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..81e25cc 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.classList.toggle('cc-hidden', !visible); + } + 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..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; @@ -23,6 +24,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 +70,14 @@ export class Panel { this.el.appendChild(header); - // Messages - this.messages = new Messages(this.el); + 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 this.input = new Input(this.el, { @@ -105,6 +113,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..6131a2c 100644 --- a/packages/widget/src/styles/widget.css +++ b/packages/widget/src/styles/widget.css @@ -469,6 +469,126 @@ color: var(--cc-text-secondary); } +/* 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; + 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..5e90d2e 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,14 @@ 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 userDataSent = false; private isStreaming = false; private eventHandlers = new Map>(); private containerEl?: HTMLElement; @@ -113,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, @@ -136,16 +160,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 +223,7 @@ export class Widget { } destroy(): void { + this.preChatFormEl?.destroy(); this.bubble?.destroy(); this.panel.destroy(); this.fab?.destroy(); @@ -223,6 +247,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; @@ -250,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({ @@ -257,6 +342,7 @@ export class Widget { message: text, pageContext, locale: this.config.locale, + ...(includeUserData ? { userData: this.userData } : {}), })) { if (chunk.error) { const errorKey = chunk.error === 'rate_limit' ? 'errorRateLimit' @@ -298,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(); + }); + }); +});