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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/prechat-form.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof chatRequestSchema>;
8 changes: 8 additions & 0 deletions packages/server/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
12 changes: 12 additions & 0 deletions packages/widget/dev.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ <h2>Configuration</h2>
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
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface WidgetChatRequest {
contentSnippet?: string;
};
locale?: string;
userData?: Record<string, string>;
}

export interface WidgetChatChunk {
Expand Down
4 changes: 4 additions & 0 deletions packages/widget/src/dom/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/widget/src/dom/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
20 changes: 18 additions & 2 deletions packages/widget/src/dom/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const CLOSE_ICON = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
export interface PanelOptions {
position: 'bottom-right' | 'bottom-left';
inline?: boolean;
preChatEnabled?: boolean;
branding: {
name: string;
avatar?: string;
Expand All @@ -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;
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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();
}
Expand Down
192 changes: 192 additions & 0 deletions packages/widget/src/dom/prechat-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import type { PreChatField } from '../widget.js';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests in this PR — the issue had 8 test cases listed in the acceptance criteria. Can you add at least the core ones? Field rendering, required field validation, email validation, and the show/skip form logic. Check packages/widget/tests/dom/ for the existing patterns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add the test cases

import type { WidgetLocaleStrings } from '../i18n.js';

export interface PreChatFormOptions {
title: string;
subtitle: string;
fields: PreChatField[];
submitLabel: string;
locale: WidgetLocaleStrings;
onSubmit: (data: Record<string, string>) => void;
}

export class PreChatForm {
private container: HTMLDivElement;
private fieldElements = new Map<string, HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>();
private errorElements = new Map<string, HTMLDivElement>();
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<string, string> = {};
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();
}
}
10 changes: 10 additions & 0 deletions packages/widget/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<string, WidgetLocaleStrings> = { en };
Expand Down
16 changes: 16 additions & 0 deletions packages/widget/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Loading
Loading