Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@syncagent/angular

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@syncagent/angular - npm Package Compare versions

Comparing version
0.4.0
to
0.5.0
+235
-3
dist/index.d.mts
import * as rxjs from 'rxjs';
import { Subject } from 'rxjs';
import * as _angular_core from '@angular/core';
import { Message, ToolData, SyncAgentConfig, GuestIdentity } from '@syncagent/js';
export { ChatOptions, ChatResult, CustomerChatOptions, CustomerChatResult, FieldValidationResult, GuestFormConfig, GuestIdentificationRequiredError, GuestIdentity, Message, SyncAgentClient, SyncAgentConfig, ToolData, ToolDefinition, ToolParameter, detectPageContext, generateGuestIdentifier, validateEmail, validateGuestForm, validateName } from '@syncagent/js';
import { OnDestroy, OnInit, EventEmitter, ElementRef } from '@angular/core';
import { Message, ToolData, SyncAgentConfig, GuestIdentity, GuestFormConfig, ThemeColors } from '@syncagent/js';
export { ChatOptions, ChatResult, CustomerChatOptions, CustomerChatResult, FieldValidationResult, GuestFormConfig, GuestIdentificationRequiredError, GuestIdentity, Message, SyncAgentClient, SyncAgentConfig, ThemeColors, ToolData, ToolDefinition, ToolParameter, detectPageContext, generateGuestIdentifier, validateEmail, validateGuestForm, validateName } from '@syncagent/js';

@@ -222,2 +224,232 @@ interface SyncAgentServiceConfig extends SyncAgentConfig {

export { CustomerChatService, type CustomerChatServiceConfig, DualChatService, type DualChatServiceConfig, SyncAgentService, type SyncAgentServiceConfig };
/**
* Message type received from Pusher channel — extends Message with a required `id` field.
*/
interface PusherMessage extends Message {
id: string;
timestamp?: string;
}
/**
* Internal Angular service managing Pusher subscription for real-time human agent messages.
* Falls back to polling when Pusher connection fails.
*
* @internal Not exported from the package barrel — used by SyncAgentCustomerChatComponent.
*/
declare class PusherService implements OnDestroy {
private pusher;
private channel;
private pollingInterval;
private existingIds;
private cancelled;
/** Whether Pusher is currently connected */
readonly isConnected: _angular_core.WritableSignal<boolean>;
/** Emits new (deduplicated) messages received via Pusher */
readonly newMessage$: Subject<PusherMessage>;
/**
* Connect to a Pusher channel for real-time messages.
*
* @param pusherKey - Pusher app key
* @param cluster - Pusher cluster (e.g. "us2")
* @param conversationId - The conversation channel to subscribe to
* @param existingIds - Set of existing message IDs for deduplication
*/
connect(pusherKey: string, cluster: string, conversationId: string, existingIds: Set<string>): void;
/**
* Disconnect from Pusher and stop polling.
* Cleans up all subscriptions and intervals.
*/
disconnect(): void;
ngOnDestroy(): void;
/**
* Update the set of existing message IDs for deduplication.
* Call this when new messages arrive from other sources (e.g. API responses).
*/
updateExistingIds(ids: Set<string>): void;
private startPolling;
private stopPolling;
private initPusher;
}
/** Display message used internally for rendering the chat panel */
interface DisplayMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: Date;
systemType?: "escalation" | "resolution" | "info";
}
/**
* Pre-built customer support chat widget for Angular.
*
* Provides a complete chat experience including guest identification,
* real-time messaging with AI and human agents, escalation display,
* and satisfaction rating — all as a single drop-in component.
*
* Configuration resolution order:
* 1. `config` input (from server config or manual object)
* 2. `apiKey` + `connectionString` individual inputs
* 3. Throws error if nothing provided
*
* ## Accessibility (WCAG AA)
*
* The template MUST include the following ARIA attributes:
*
* - Container: `role="region"` with `aria-label="Customer chat"`
* - Chat panel (message list): `role="log"` with `aria-live="polite"` and `aria-label="Chat messages"` and `aria-relevant="additions"`
* - Header: `aria-label="Chat header"`
* - Toggle button (floating mode): `aria-label="Open chat"/"Close chat"` with `aria-expanded`
* - Close button: `aria-label="Close chat"`
* - Message input form: `role="form"` with `aria-label="Message input"`
* - Message input field: `aria-label="Type your message"` with `aria-disabled` when loading
* - Send button: `aria-label="Send message"` with `aria-disabled` when cannot submit
* - Rating container: `role="group"` with `aria-label="Rate your experience"`
* - Rating stars container: `role="radiogroup"` with `aria-label="Rating from 1 to 5 stars"`
* - Each star: `role="radio"` with `aria-checked`, `aria-label="N star(s)"`, roving tabindex
* - Escalation banner: `role="status"` with `aria-live="polite"` and `aria-label="Escalation status"`
* - Loading indicator: `role="status"` with `aria-label="Loading response"`
* - Guest form fields: proper `<label>` elements or `aria-label` attributes
* - Decorative icons: `aria-hidden="true"`
*
* Keyboard navigation:
* - Tab: moves through interactive elements (toggle, close, input, send, stars)
* - Enter/Space: activates buttons and submits forms
* - Arrow Left/Right: navigates between rating stars (roving tabindex)
* - Enter on input: submits message
*
* Focus management:
* - On guest form → chat transition: focus moves to Message_Input within 100ms
* - All interactive elements have visible focus indicators (2px outline, 3:1 contrast)
* - Focus indicators use the accent color from the theme via CSS custom property
*
* @example
* ```html
* <syncagent-customer-chat
* [apiKey]="apiKey"
* [connectionString]="connectionString"
* [accentColor]="'#6366f1'"
* (escalated)="onEscalated()"
* (resolved)="onResolved($event)"
* />
* ```
*/
declare class SyncAgentCustomerChatComponent implements OnInit, OnDestroy {
chatService: CustomerChatService;
private pusherService;
/** Server config object. When provided, individual apiKey/connectionString inputs are ignored. */
config?: SyncAgentConfig;
/** API key for authentication (ignored if config provided) */
apiKey?: string;
/** Database connection string (ignored if config provided) */
connectionString?: string;
/** Authenticated user ID — skips guest form when provided */
externalUserId?: string;
/** "floating" (fixed-position toggle) or "inline" (fills parent). Default: "floating" */
mode: "floating" | "inline";
/** Position of floating widget. Default: "bottom-right" */
position: "bottom-right" | "bottom-left";
/** Whether floating panel starts open. Default: false */
defaultOpen: boolean;
/** Header title text (max 100 chars). Default: "Customer Support" */
title: string;
/** Header subtitle text (max 200 chars). Default: "How can we help you?" */
subtitle: string;
/** Input placeholder text (max 150 chars). Default: "Type your message..." */
placeholder: string;
/** Initial welcome message displayed before any interaction */
welcomeMessage?: string;
/** Primary accent color (hex, rgb, or hsl). Default: "#6366f1" */
accentColor?: string;
/** Enable dark mode color scheme. Default: false */
darkMode: boolean;
/** Additional CSS class on root container */
className?: string;
/** Custom guest form configuration (title, subtitle, placeholders, button text) */
guestForm?: GuestFormConfig;
/** Pusher app key for real-time human agent messages */
pusherKey?: string;
/** Pusher cluster (default: "us2") */
pusherCluster?: string;
/** Custom metadata attached to conversations */
metadata?: Record<string, any>;
/** Emitted when conversation is escalated to human agent */
escalated: EventEmitter<void>;
/** Emitted when conversation is resolved, with conversation ID */
resolved: EventEmitter<string>;
/** Emitted after guest form submission with the guest identity */
guestIdentified: EventEmitter<GuestIdentity>;
/** Whether the chat panel is open (floating mode only) */
readonly isOpen: _angular_core.WritableSignal<boolean>;
/** Messages received via Pusher (human agent messages during escalation) */
readonly pusherMessages: _angular_core.WritableSignal<DisplayMessage[]>;
/** Whether a satisfaction rating has been submitted */
readonly ratingSubmitted: _angular_core.WritableSignal<boolean>;
/** Current message input value */
readonly inputValue: _angular_core.WritableSignal<string>;
/** Guest form field values */
readonly guestName: _angular_core.WritableSignal<string>;
readonly guestEmail: _angular_core.WritableSignal<string>;
readonly guestPhone: _angular_core.WritableSignal<string>;
/** Guest form validation errors */
readonly guestErrors: _angular_core.WritableSignal<Record<string, string>>;
/** Hovered star rating (0 = none) */
readonly hoveredRating: _angular_core.WritableSignal<number>;
/** Selected star rating (0 = none) */
readonly selectedRating: _angular_core.WritableSignal<number>;
/** Whether the escalation callback has been fired (prevents duplicates) */
private escalationFired;
/** Track previous isIdentified state for focus management */
private wasIdentified;
/** Computed theme colors from accent color and dark mode flag */
readonly theme: _angular_core.Signal<ThemeColors>;
/** Computed: whether the send button should be enabled */
readonly canSend: _angular_core.Signal<boolean>;
/** Combined display messages from service messages + Pusher messages */
readonly displayMessages: _angular_core.Signal<DisplayMessage[]>;
/** Effective welcome message: prop takes precedence over API-returned */
readonly effectiveWelcomeMessage: _angular_core.Signal<string | null>;
/** Set of existing message IDs for Pusher deduplication */
readonly existingMessageIds: _angular_core.Signal<Set<string>>;
messageInputRef?: ElementRef<HTMLInputElement>;
/** Effect: fire escalated output event exactly once per escalation */
private escalationEffect;
/** Effect: fire resolved output event and disconnect Pusher */
private resolvedEffect;
/** Effect: focus management on guest form → chat transition */
private focusEffect;
/** Effect: emit guestIdentified when guest identity changes */
private guestIdentifiedEffect;
constructor(chatService: CustomerChatService, pusherService: PusherService);
ngOnInit(): void;
ngOnDestroy(): void;
/** Toggle the chat panel open/closed (floating mode) */
togglePanel(): void;
/** Close the chat panel (floating mode) */
closePanel(): void;
/** Send a message */
sendMessage(): Promise<void>;
/** Submit guest identification form */
submitGuestForm(): void;
/** Submit a satisfaction rating */
rateConversation(rating: number): Promise<void>;
/** Handle guest form submission with validation */
handleGuestSubmit(): void;
/** Handle send message from form */
handleSend(event: Event): void;
/** Handle rating star click */
handleRating(star: number): Promise<void>;
/** Handle keyboard navigation for rating stars */
handleRatingKeydown(event: KeyboardEvent, star: number): void;
/** TrackBy function for message list */
trackMessage(_index: number, msg: DisplayMessage): string;
/**
* Resolves the configuration for the customer chat component.
*
* Priority order:
* 1. `config` input — use directly
* 2. `apiKey` + `connectionString` individual inputs — build config
* 3. Throw error if nothing provided
*/
private resolveConfig;
}
export { CustomerChatService, type CustomerChatServiceConfig, DualChatService, type DualChatServiceConfig, SyncAgentCustomerChatComponent, SyncAgentService, type SyncAgentServiceConfig };
import * as rxjs from 'rxjs';
import { Subject } from 'rxjs';
import * as _angular_core from '@angular/core';
import { Message, ToolData, SyncAgentConfig, GuestIdentity } from '@syncagent/js';
export { ChatOptions, ChatResult, CustomerChatOptions, CustomerChatResult, FieldValidationResult, GuestFormConfig, GuestIdentificationRequiredError, GuestIdentity, Message, SyncAgentClient, SyncAgentConfig, ToolData, ToolDefinition, ToolParameter, detectPageContext, generateGuestIdentifier, validateEmail, validateGuestForm, validateName } from '@syncagent/js';
import { OnDestroy, OnInit, EventEmitter, ElementRef } from '@angular/core';
import { Message, ToolData, SyncAgentConfig, GuestIdentity, GuestFormConfig, ThemeColors } from '@syncagent/js';
export { ChatOptions, ChatResult, CustomerChatOptions, CustomerChatResult, FieldValidationResult, GuestFormConfig, GuestIdentificationRequiredError, GuestIdentity, Message, SyncAgentClient, SyncAgentConfig, ThemeColors, ToolData, ToolDefinition, ToolParameter, detectPageContext, generateGuestIdentifier, validateEmail, validateGuestForm, validateName } from '@syncagent/js';

@@ -222,2 +224,232 @@ interface SyncAgentServiceConfig extends SyncAgentConfig {

export { CustomerChatService, type CustomerChatServiceConfig, DualChatService, type DualChatServiceConfig, SyncAgentService, type SyncAgentServiceConfig };
/**
* Message type received from Pusher channel — extends Message with a required `id` field.
*/
interface PusherMessage extends Message {
id: string;
timestamp?: string;
}
/**
* Internal Angular service managing Pusher subscription for real-time human agent messages.
* Falls back to polling when Pusher connection fails.
*
* @internal Not exported from the package barrel — used by SyncAgentCustomerChatComponent.
*/
declare class PusherService implements OnDestroy {
private pusher;
private channel;
private pollingInterval;
private existingIds;
private cancelled;
/** Whether Pusher is currently connected */
readonly isConnected: _angular_core.WritableSignal<boolean>;
/** Emits new (deduplicated) messages received via Pusher */
readonly newMessage$: Subject<PusherMessage>;
/**
* Connect to a Pusher channel for real-time messages.
*
* @param pusherKey - Pusher app key
* @param cluster - Pusher cluster (e.g. "us2")
* @param conversationId - The conversation channel to subscribe to
* @param existingIds - Set of existing message IDs for deduplication
*/
connect(pusherKey: string, cluster: string, conversationId: string, existingIds: Set<string>): void;
/**
* Disconnect from Pusher and stop polling.
* Cleans up all subscriptions and intervals.
*/
disconnect(): void;
ngOnDestroy(): void;
/**
* Update the set of existing message IDs for deduplication.
* Call this when new messages arrive from other sources (e.g. API responses).
*/
updateExistingIds(ids: Set<string>): void;
private startPolling;
private stopPolling;
private initPusher;
}
/** Display message used internally for rendering the chat panel */
interface DisplayMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: Date;
systemType?: "escalation" | "resolution" | "info";
}
/**
* Pre-built customer support chat widget for Angular.
*
* Provides a complete chat experience including guest identification,
* real-time messaging with AI and human agents, escalation display,
* and satisfaction rating — all as a single drop-in component.
*
* Configuration resolution order:
* 1. `config` input (from server config or manual object)
* 2. `apiKey` + `connectionString` individual inputs
* 3. Throws error if nothing provided
*
* ## Accessibility (WCAG AA)
*
* The template MUST include the following ARIA attributes:
*
* - Container: `role="region"` with `aria-label="Customer chat"`
* - Chat panel (message list): `role="log"` with `aria-live="polite"` and `aria-label="Chat messages"` and `aria-relevant="additions"`
* - Header: `aria-label="Chat header"`
* - Toggle button (floating mode): `aria-label="Open chat"/"Close chat"` with `aria-expanded`
* - Close button: `aria-label="Close chat"`
* - Message input form: `role="form"` with `aria-label="Message input"`
* - Message input field: `aria-label="Type your message"` with `aria-disabled` when loading
* - Send button: `aria-label="Send message"` with `aria-disabled` when cannot submit
* - Rating container: `role="group"` with `aria-label="Rate your experience"`
* - Rating stars container: `role="radiogroup"` with `aria-label="Rating from 1 to 5 stars"`
* - Each star: `role="radio"` with `aria-checked`, `aria-label="N star(s)"`, roving tabindex
* - Escalation banner: `role="status"` with `aria-live="polite"` and `aria-label="Escalation status"`
* - Loading indicator: `role="status"` with `aria-label="Loading response"`
* - Guest form fields: proper `<label>` elements or `aria-label` attributes
* - Decorative icons: `aria-hidden="true"`
*
* Keyboard navigation:
* - Tab: moves through interactive elements (toggle, close, input, send, stars)
* - Enter/Space: activates buttons and submits forms
* - Arrow Left/Right: navigates between rating stars (roving tabindex)
* - Enter on input: submits message
*
* Focus management:
* - On guest form → chat transition: focus moves to Message_Input within 100ms
* - All interactive elements have visible focus indicators (2px outline, 3:1 contrast)
* - Focus indicators use the accent color from the theme via CSS custom property
*
* @example
* ```html
* <syncagent-customer-chat
* [apiKey]="apiKey"
* [connectionString]="connectionString"
* [accentColor]="'#6366f1'"
* (escalated)="onEscalated()"
* (resolved)="onResolved($event)"
* />
* ```
*/
declare class SyncAgentCustomerChatComponent implements OnInit, OnDestroy {
chatService: CustomerChatService;
private pusherService;
/** Server config object. When provided, individual apiKey/connectionString inputs are ignored. */
config?: SyncAgentConfig;
/** API key for authentication (ignored if config provided) */
apiKey?: string;
/** Database connection string (ignored if config provided) */
connectionString?: string;
/** Authenticated user ID — skips guest form when provided */
externalUserId?: string;
/** "floating" (fixed-position toggle) or "inline" (fills parent). Default: "floating" */
mode: "floating" | "inline";
/** Position of floating widget. Default: "bottom-right" */
position: "bottom-right" | "bottom-left";
/** Whether floating panel starts open. Default: false */
defaultOpen: boolean;
/** Header title text (max 100 chars). Default: "Customer Support" */
title: string;
/** Header subtitle text (max 200 chars). Default: "How can we help you?" */
subtitle: string;
/** Input placeholder text (max 150 chars). Default: "Type your message..." */
placeholder: string;
/** Initial welcome message displayed before any interaction */
welcomeMessage?: string;
/** Primary accent color (hex, rgb, or hsl). Default: "#6366f1" */
accentColor?: string;
/** Enable dark mode color scheme. Default: false */
darkMode: boolean;
/** Additional CSS class on root container */
className?: string;
/** Custom guest form configuration (title, subtitle, placeholders, button text) */
guestForm?: GuestFormConfig;
/** Pusher app key for real-time human agent messages */
pusherKey?: string;
/** Pusher cluster (default: "us2") */
pusherCluster?: string;
/** Custom metadata attached to conversations */
metadata?: Record<string, any>;
/** Emitted when conversation is escalated to human agent */
escalated: EventEmitter<void>;
/** Emitted when conversation is resolved, with conversation ID */
resolved: EventEmitter<string>;
/** Emitted after guest form submission with the guest identity */
guestIdentified: EventEmitter<GuestIdentity>;
/** Whether the chat panel is open (floating mode only) */
readonly isOpen: _angular_core.WritableSignal<boolean>;
/** Messages received via Pusher (human agent messages during escalation) */
readonly pusherMessages: _angular_core.WritableSignal<DisplayMessage[]>;
/** Whether a satisfaction rating has been submitted */
readonly ratingSubmitted: _angular_core.WritableSignal<boolean>;
/** Current message input value */
readonly inputValue: _angular_core.WritableSignal<string>;
/** Guest form field values */
readonly guestName: _angular_core.WritableSignal<string>;
readonly guestEmail: _angular_core.WritableSignal<string>;
readonly guestPhone: _angular_core.WritableSignal<string>;
/** Guest form validation errors */
readonly guestErrors: _angular_core.WritableSignal<Record<string, string>>;
/** Hovered star rating (0 = none) */
readonly hoveredRating: _angular_core.WritableSignal<number>;
/** Selected star rating (0 = none) */
readonly selectedRating: _angular_core.WritableSignal<number>;
/** Whether the escalation callback has been fired (prevents duplicates) */
private escalationFired;
/** Track previous isIdentified state for focus management */
private wasIdentified;
/** Computed theme colors from accent color and dark mode flag */
readonly theme: _angular_core.Signal<ThemeColors>;
/** Computed: whether the send button should be enabled */
readonly canSend: _angular_core.Signal<boolean>;
/** Combined display messages from service messages + Pusher messages */
readonly displayMessages: _angular_core.Signal<DisplayMessage[]>;
/** Effective welcome message: prop takes precedence over API-returned */
readonly effectiveWelcomeMessage: _angular_core.Signal<string | null>;
/** Set of existing message IDs for Pusher deduplication */
readonly existingMessageIds: _angular_core.Signal<Set<string>>;
messageInputRef?: ElementRef<HTMLInputElement>;
/** Effect: fire escalated output event exactly once per escalation */
private escalationEffect;
/** Effect: fire resolved output event and disconnect Pusher */
private resolvedEffect;
/** Effect: focus management on guest form → chat transition */
private focusEffect;
/** Effect: emit guestIdentified when guest identity changes */
private guestIdentifiedEffect;
constructor(chatService: CustomerChatService, pusherService: PusherService);
ngOnInit(): void;
ngOnDestroy(): void;
/** Toggle the chat panel open/closed (floating mode) */
togglePanel(): void;
/** Close the chat panel (floating mode) */
closePanel(): void;
/** Send a message */
sendMessage(): Promise<void>;
/** Submit guest identification form */
submitGuestForm(): void;
/** Submit a satisfaction rating */
rateConversation(rating: number): Promise<void>;
/** Handle guest form submission with validation */
handleGuestSubmit(): void;
/** Handle send message from form */
handleSend(event: Event): void;
/** Handle rating star click */
handleRating(star: number): Promise<void>;
/** Handle keyboard navigation for rating stars */
handleRatingKeydown(event: KeyboardEvent, star: number): void;
/** TrackBy function for message list */
trackMessage(_index: number, msg: DisplayMessage): string;
/**
* Resolves the configuration for the customer chat component.
*
* Priority order:
* 1. `config` input — use directly
* 2. `apiKey` + `connectionString` individual inputs — build config
* 3. Throw error if nothing provided
*/
private resolveConfig;
}
export { CustomerChatService, type CustomerChatServiceConfig, DualChatService, type DualChatServiceConfig, SyncAgentCustomerChatComponent, SyncAgentService, type SyncAgentServiceConfig };
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;

@@ -18,2 +20,10 @@ var __export = (target, all) => {

};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

@@ -34,10 +44,11 @@ var __decorateClass = (decorators, target, key, kind) => {

DualChatService: () => DualChatService,
GuestIdentificationRequiredError: () => import_js5.GuestIdentificationRequiredError,
SyncAgentClient: () => import_js4.SyncAgentClient,
GuestIdentificationRequiredError: () => import_js6.GuestIdentificationRequiredError,
SyncAgentClient: () => import_js5.SyncAgentClient,
SyncAgentCustomerChatComponent: () => SyncAgentCustomerChatComponent,
SyncAgentService: () => SyncAgentService,
detectPageContext: () => import_js4.detectPageContext,
generateGuestIdentifier: () => import_js5.generateGuestIdentifier,
validateEmail: () => import_js5.validateEmail,
validateGuestForm: () => import_js5.validateGuestForm,
validateName: () => import_js5.validateName
detectPageContext: () => import_js5.detectPageContext,
generateGuestIdentifier: () => import_js6.generateGuestIdentifier,
validateEmail: () => import_js6.validateEmail,
validateGuestForm: () => import_js6.validateGuestForm,
validateName: () => import_js6.validateName
});

@@ -498,5 +509,1093 @@ module.exports = __toCommonJS(index_exports);

// src/customer-chat.component.ts
var import_core5 = require("@angular/core");
var import_common = require("@angular/common");
var import_forms = require("@angular/forms");
var import_js4 = require("@syncagent/js");
// src/pusher.service.ts
var import_core4 = require("@angular/core");
var import_rxjs4 = require("rxjs");
var POLLING_INTERVAL_MS = 5e3;
var PusherService = class {
constructor() {
this.pusher = null;
this.channel = null;
this.pollingInterval = null;
this.existingIds = /* @__PURE__ */ new Set();
this.cancelled = false;
/** Whether Pusher is currently connected */
this.isConnected = (0, import_core4.signal)(false);
/** Emits new (deduplicated) messages received via Pusher */
this.newMessage$ = new import_rxjs4.Subject();
}
/**
* Connect to a Pusher channel for real-time messages.
*
* @param pusherKey - Pusher app key
* @param cluster - Pusher cluster (e.g. "us2")
* @param conversationId - The conversation channel to subscribe to
* @param existingIds - Set of existing message IDs for deduplication
*/
connect(pusherKey, cluster, conversationId, existingIds) {
this.disconnect();
this.existingIds = new Set(existingIds);
this.cancelled = false;
const channelName = `conversation-${conversationId}`;
this.initPusher(pusherKey, cluster, channelName);
}
/**
* Disconnect from Pusher and stop polling.
* Cleans up all subscriptions and intervals.
*/
disconnect() {
this.cancelled = true;
this.stopPolling();
if (this.channel) {
this.channel.unbind_all();
this.channel = null;
}
if (this.pusher) {
this.pusher.disconnect();
this.pusher = null;
}
this.isConnected.set(false);
}
ngOnDestroy() {
this.disconnect();
this.newMessage$.complete();
}
/**
* Update the set of existing message IDs for deduplication.
* Call this when new messages arrive from other sources (e.g. API responses).
*/
updateExistingIds(ids) {
this.existingIds = new Set(ids);
}
startPolling() {
if (this.pollingInterval !== null) return;
this.pollingInterval = setInterval(() => {
}, POLLING_INTERVAL_MS);
}
stopPolling() {
if (this.pollingInterval !== null) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
async initPusher(pusherKey, cluster, channelName) {
try {
const PusherModule = await import("pusher-js");
const Pusher = PusherModule.default || PusherModule;
if (this.cancelled) return;
const pusher = new Pusher(pusherKey, { cluster });
this.pusher = pusher;
pusher.connection.bind("connected", () => {
if (this.cancelled) return;
this.isConnected.set(true);
this.stopPolling();
});
pusher.connection.bind("disconnected", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
pusher.connection.bind("error", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
pusher.connection.bind("failed", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
const channel = pusher.subscribe(channelName);
this.channel = channel;
channel.bind("new-message", (data) => {
if (this.cancelled) return;
if (data.id && this.existingIds.has(data.id)) {
return;
}
this.existingIds.add(data.id);
this.newMessage$.next(data);
});
channel.bind("pusher:subscription_error", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
} catch {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
}
}
};
PusherService = __decorateClass([
(0, import_core4.Injectable)()
], PusherService);
// src/customer-chat.component.ts
var SyncAgentCustomerChatComponent = class {
// ─── Constructor ──────────────────────────────────────────────────────────────
constructor(chatService, pusherService) {
this.chatService = chatService;
this.pusherService = pusherService;
this.mode = "floating";
this.position = "bottom-right";
this.defaultOpen = false;
this.title = "Customer Support";
this.subtitle = "How can we help you?";
this.placeholder = "Type your message...";
this.darkMode = false;
this.escalated = new import_core5.EventEmitter();
this.resolved = new import_core5.EventEmitter();
this.guestIdentified = new import_core5.EventEmitter();
// ─── Internal State (Signals) ─────────────────────────────────────────────────
/** Whether the chat panel is open (floating mode only) */
this.isOpen = (0, import_core5.signal)(false);
/** Messages received via Pusher (human agent messages during escalation) */
this.pusherMessages = (0, import_core5.signal)([]);
/** Whether a satisfaction rating has been submitted */
this.ratingSubmitted = (0, import_core5.signal)(false);
/** Current message input value */
this.inputValue = (0, import_core5.signal)("");
/** Guest form field values */
this.guestName = (0, import_core5.signal)("");
this.guestEmail = (0, import_core5.signal)("");
this.guestPhone = (0, import_core5.signal)("");
/** Guest form validation errors */
this.guestErrors = (0, import_core5.signal)({});
/** Hovered star rating (0 = none) */
this.hoveredRating = (0, import_core5.signal)(0);
/** Selected star rating (0 = none) */
this.selectedRating = (0, import_core5.signal)(0);
/** Whether the escalation callback has been fired (prevents duplicates) */
this.escalationFired = false;
/** Track previous isIdentified state for focus management */
this.wasIdentified = null;
// ─── Computed Values ──────────────────────────────────────────────────────────
/** Computed theme colors from accent color and dark mode flag */
this.theme = (0, import_core5.computed)(
() => (0, import_js4.computeTheme)(this.accentColor, this.darkMode)
);
/** Computed: whether the send button should be enabled */
this.canSend = (0, import_core5.computed)(
() => this.inputValue().trim().length > 0 && !this.chatService.isLoading()
);
/** Combined display messages from service messages + Pusher messages */
this.displayMessages = (0, import_core5.computed)(() => {
const serviceMessages = this.chatService.messages().map(
(msg, idx) => ({
id: `msg-${idx}`,
role: msg.role,
content: msg.content,
timestamp: /* @__PURE__ */ new Date()
})
);
const combined = [...serviceMessages, ...this.pusherMessages()];
const error = this.chatService.error();
if (error) {
combined.push({
id: "error-inline",
role: "system",
content: error.message || "Something went wrong. Please try again.",
timestamp: /* @__PURE__ */ new Date(),
systemType: "info"
});
}
return combined;
});
/** Effective welcome message: prop takes precedence over API-returned */
this.effectiveWelcomeMessage = (0, import_core5.computed)(
() => this.welcomeMessage ?? this.chatService.welcomeMessage()
);
/** Set of existing message IDs for Pusher deduplication */
this.existingMessageIds = (0, import_core5.computed)(() => {
const ids = /* @__PURE__ */ new Set();
this.chatService.messages().forEach((_msg, idx) => ids.add(`msg-${idx}`));
this.pusherMessages().forEach((msg) => ids.add(msg.id));
return ids;
});
// ─── Effects ──────────────────────────────────────────────────────────────────
/** Effect: fire escalated output event exactly once per escalation */
this.escalationEffect = (0, import_core5.effect)(() => {
const isEscalated = this.chatService.isEscalated();
if (isEscalated && !this.escalationFired) {
this.escalationFired = true;
this.escalated.emit();
if (this.pusherKey) {
this.pusherService.connect(
this.pusherKey,
this.pusherCluster || "us2",
this.chatService.conversationId() || "",
this.existingMessageIds()
);
}
}
if (!isEscalated && this.escalationFired) {
this.escalationFired = false;
}
});
/** Effect: fire resolved output event and disconnect Pusher */
this.resolvedEffect = (0, import_core5.effect)(() => {
const isResolved = this.chatService.isResolved();
const conversationId = this.chatService.conversationId();
if (isResolved && conversationId) {
this.resolved.emit(conversationId);
this.pusherService.disconnect();
this.pusherMessages.set([]);
}
});
/** Effect: focus management on guest form → chat transition */
this.focusEffect = (0, import_core5.effect)(() => {
const isIdentified = this.chatService.isIdentified();
if (this.wasIdentified === null) {
this.wasIdentified = isIdentified;
return;
}
if (!this.wasIdentified && isIdentified) {
setTimeout(() => {
this.messageInputRef?.nativeElement?.focus();
}, 50);
}
this.wasIdentified = isIdentified;
});
/** Effect: emit guestIdentified when guest identity changes */
this.guestIdentifiedEffect = (0, import_core5.effect)(() => {
const identity = this.chatService.guestIdentity();
if (identity) {
this.guestIdentified.emit(identity);
}
});
this.pusherService.newMessage$.subscribe((message) => {
const displayMsg = {
id: message.id,
role: "assistant",
content: message.content || "",
timestamp: message.timestamp ? new Date(message.timestamp) : /* @__PURE__ */ new Date()
};
this.pusherMessages.update((prev) => [...prev, displayMsg]);
});
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
ngOnInit() {
this.isOpen.set(this.mode === "inline" ? true : this.defaultOpen);
const resolvedConfig = this.resolveConfig();
this.chatService.configure({
...resolvedConfig,
externalUserId: this.externalUserId,
onEscalated: () => {
},
onResolved: () => {
},
onGuestIdentified: () => {
}
});
}
ngOnDestroy() {
this.pusherService.disconnect();
}
// ─── Public Methods (for template) ────────────────────────────────────────────
/** Toggle the chat panel open/closed (floating mode) */
togglePanel() {
this.isOpen.update((v) => !v);
}
/** Close the chat panel (floating mode) */
closePanel() {
this.isOpen.set(false);
}
/** Send a message */
async sendMessage() {
const content = this.inputValue().trim();
if (!content) return;
this.inputValue.set("");
await this.chatService.sendMessage(content, this.metadata);
}
/** Submit guest identification form */
submitGuestForm() {
this.chatService.identifyGuest({
name: this.guestName(),
email: this.guestEmail(),
phone: this.guestPhone() || void 0
});
}
/** Submit a satisfaction rating */
async rateConversation(rating) {
if (this.ratingSubmitted()) return;
await this.chatService.rateConversation(rating);
this.ratingSubmitted.set(true);
}
/** Handle guest form submission with validation */
handleGuestSubmit() {
const errors = {};
const name = this.guestName().trim();
const email = this.guestEmail().trim();
if (!name) {
errors["name"] = "Name is required";
}
if (!email) {
errors["email"] = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors["email"] = "Please enter a valid email address";
}
this.guestErrors.set(errors);
if (Object.keys(errors).length === 0) {
this.submitGuestForm();
}
}
/** Handle send message from form */
handleSend(event) {
event.preventDefault();
this.sendMessage();
}
/** Handle rating star click */
async handleRating(star) {
if (this.ratingSubmitted()) return;
this.selectedRating.set(star);
await this.rateConversation(star);
}
/** Handle keyboard navigation for rating stars */
handleRatingKeydown(event, star) {
if (this.ratingSubmitted()) return;
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
const next = Math.min(star + 1, 5);
const buttons = event.target.parentElement?.querySelectorAll("button");
if (buttons && buttons[next - 1]) {
buttons[next - 1].focus();
}
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
const prev = Math.max(star - 1, 1);
const buttons = event.target.parentElement?.querySelectorAll("button");
if (buttons && buttons[prev - 1]) {
buttons[prev - 1].focus();
}
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.handleRating(star);
}
}
/** TrackBy function for message list */
trackMessage(_index, msg) {
return msg.id;
}
// ─── Private Methods ──────────────────────────────────────────────────────────
/**
* Resolves the configuration for the customer chat component.
*
* Priority order:
* 1. `config` input — use directly
* 2. `apiKey` + `connectionString` individual inputs — build config
* 3. Throw error if nothing provided
*/
resolveConfig() {
if (this.config) {
return this.config;
}
if (this.apiKey !== void 0 || this.connectionString !== void 0) {
if (!this.apiKey || this.apiKey.trim() === "") {
throw new Error(
"SyncAgentCustomerChat: apiKey is required and cannot be empty"
);
}
if (!this.connectionString || this.connectionString.trim() === "") {
throw new Error(
"SyncAgentCustomerChat: connectionString is required and cannot be empty"
);
}
return {
apiKey: this.apiKey,
connectionString: this.connectionString,
customerMode: true,
externalUserId: this.externalUserId
};
}
throw new Error(
"SyncAgentCustomerChat: Either provide a `config` input or both `apiKey` and `connectionString` inputs"
);
}
};
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "config", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "apiKey", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "connectionString", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "externalUserId", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "mode", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "position", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "defaultOpen", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "title", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "subtitle", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "placeholder", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "welcomeMessage", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "accentColor", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "darkMode", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "className", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "guestForm", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "pusherKey", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "pusherCluster", 2);
__decorateClass([
(0, import_core5.Input)()
], SyncAgentCustomerChatComponent.prototype, "metadata", 2);
__decorateClass([
(0, import_core5.Output)()
], SyncAgentCustomerChatComponent.prototype, "escalated", 2);
__decorateClass([
(0, import_core5.Output)()
], SyncAgentCustomerChatComponent.prototype, "resolved", 2);
__decorateClass([
(0, import_core5.Output)()
], SyncAgentCustomerChatComponent.prototype, "guestIdentified", 2);
__decorateClass([
(0, import_core5.ViewChild)("messageInput")
], SyncAgentCustomerChatComponent.prototype, "messageInputRef", 2);
SyncAgentCustomerChatComponent = __decorateClass([
(0, import_core5.Component)({
selector: "syncagent-customer-chat",
standalone: true,
imports: [import_common.CommonModule, import_forms.FormsModule],
encapsulation: import_core5.ViewEncapsulation.None,
providers: [CustomerChatService, PusherService],
template: `
<!-- Floating mode: toggle button -->
<button
*ngIf="mode === 'floating'"
type="button"
class="syncagent-cc-toggle"
[style.position]="'fixed'"
[style.bottom]="'20px'"
[style.right]="position === 'bottom-right' ? '20px' : 'auto'"
[style.left]="position === 'bottom-left' ? '20px' : 'auto'"
[style.width]="'56px'"
[style.height]="'56px'"
[style.border-radius]="'50%'"
[style.background-color]="theme().accent"
[style.color]="theme().userBubbleText"
[style.border]="'none'"
[style.cursor]="'pointer'"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.box-shadow]="'0 4px 12px rgba(0, 0, 0, 0.15)'"
[style.z-index]="9999"
[style.outline]="'none'"
[attr.aria-label]="isOpen() ? 'Close chat' : 'Open chat'"
[attr.aria-expanded]="isOpen()"
(click)="togglePanel()"
>
<!-- Chat icon when closed -->
<svg *ngIf="!isOpen()" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<!-- Close icon when open -->
<svg *ngIf="isOpen()" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Chat panel container -->
<div
*ngIf="mode === 'inline' || isOpen()"
class="syncagent-cc-container"
[class]="className || ''"
[style.position]="mode === 'floating' ? 'fixed' : 'relative'"
[style.bottom]="mode === 'floating' ? '88px' : 'auto'"
[style.right]="mode === 'floating' && position === 'bottom-right' ? '20px' : 'auto'"
[style.left]="mode === 'floating' && position === 'bottom-left' ? '20px' : 'auto'"
[style.width]="mode === 'floating' ? '380px' : '100%'"
[style.height]="mode === 'floating' ? '520px' : '100%'"
[style.border-radius]="'12px'"
[style.box-shadow]="mode === 'floating' ? '0 8px 32px rgba(0, 0, 0, 0.12)' : 'none'"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.overflow]="'hidden'"
[style.background-color]="theme().background"
[style.border]="'1px solid ' + theme().border"
[style.z-index]="mode === 'floating' ? 9998 : 'auto'"
[style.font-family]="'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'"
role="region"
aria-label="Customer support chat"
>
<!-- Header -->
<div
class="syncagent-cc-header"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'space-between'"
[style.padding]="'16px 20px'"
[style.background-color]="theme().accent"
[style.border-radius]="'12px 12px 0 0'"
[style.flex-shrink]="0"
>
<div [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'2px'" [style.min-width]="0" [style.flex]="1">
<h2
class="syncagent-cc-title"
[style.font-size]="'15px'"
[style.font-weight]="700"
[style.color]="theme().userBubbleText"
[style.margin]="0"
[style.line-height]="'1.3'"
[style.overflow]="'hidden'"
[style.text-overflow]="'ellipsis'"
[style.white-space]="'nowrap'"
>{{ title }}</h2>
<p
class="syncagent-cc-subtitle"
[style.font-size]="'12.5px'"
[style.font-weight]="400"
[style.color]="theme().userBubbleText"
[style.opacity]="0.85"
[style.margin]="0"
[style.line-height]="'1.4'"
[style.overflow]="'hidden'"
[style.text-overflow]="'ellipsis'"
[style.white-space]="'nowrap'"
>{{ subtitle }}</p>
</div>
<!-- Close button (floating mode only) -->
<button
*ngIf="mode === 'floating'"
type="button"
class="syncagent-cc-close-btn"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.width]="'28px'"
[style.height]="'28px'"
[style.border-radius]="'50%'"
[style.border]="'none'"
[style.background]="'rgba(255, 255, 255, 0.2)'"
[style.color]="theme().userBubbleText"
[style.cursor]="'pointer'"
[style.font-size]="'16px'"
[style.line-height]="1"
[style.flex-shrink]="0"
[style.margin-left]="'12px'"
[style.outline]="'none'"
aria-label="Close chat"
(click)="closePanel()"
>\u2715</button>
</div>
<!-- Guest Form (shown when not identified) -->
<div
*ngIf="!chatService.isIdentified()"
class="syncagent-cc-guest-form"
[style.flex]="1"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.padding]="'24px 20px'"
[style.gap]="'16px'"
[style.overflow-y]="'auto'"
[style.background-color]="theme().background"
>
<div [style.text-align]="'center'" [style.margin-bottom]="'8px'">
<h3
[style.font-size]="'16px'"
[style.font-weight]="600"
[style.color]="theme().text"
[style.margin]="'0 0 4px 0'"
>{{ guestForm?.title || 'Welcome!' }}</h3>
<p
[style.font-size]="'13px'"
[style.color]="theme().textSecondary"
[style.margin]="0"
>{{ guestForm?.subtitle || 'Please introduce yourself to get started.' }}</p>
</div>
<!-- Name field -->
<div class="syncagent-cc-field" [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'4px'">
<label
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().text"
for="syncagent-guest-name"
>Name *</label>
<input
id="syncagent-guest-name"
type="text"
[placeholder]="guestForm?.namePlaceholder || 'Your name'"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + (guestErrors()['name'] ? '#ef4444' : theme().inputBorder)"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[ngModel]="guestName()"
(ngModelChange)="guestName.set($event)"
aria-required="true"
[attr.aria-invalid]="!!guestErrors()['name']"
[attr.aria-describedby]="guestErrors()['name'] ? 'syncagent-name-error' : null"
/>
<span
*ngIf="guestErrors()['name']"
id="syncagent-name-error"
[style.font-size]="'12px'"
[style.color]="'#ef4444'"
role="alert"
>{{ guestErrors()['name'] }}</span>
</div>
<!-- Email field -->
<div class="syncagent-cc-field" [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'4px'">
<label
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().text"
for="syncagent-guest-email"
>Email *</label>
<input
id="syncagent-guest-email"
type="email"
[placeholder]="guestForm?.emailPlaceholder || 'your@email.com'"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + (guestErrors()['email'] ? '#ef4444' : theme().inputBorder)"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[ngModel]="guestEmail()"
(ngModelChange)="guestEmail.set($event)"
aria-required="true"
[attr.aria-invalid]="!!guestErrors()['email']"
[attr.aria-describedby]="guestErrors()['email'] ? 'syncagent-email-error' : null"
/>
<span
*ngIf="guestErrors()['email']"
id="syncagent-email-error"
[style.font-size]="'12px'"
[style.color]="'#ef4444'"
role="alert"
>{{ guestErrors()['email'] }}</span>
</div>
<!-- Phone field -->
<div class="syncagent-cc-field" [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'4px'">
<label
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().text"
for="syncagent-guest-phone"
>Phone</label>
<input
id="syncagent-guest-phone"
type="tel"
[placeholder]="guestForm?.phonePlaceholder || 'Phone number (optional)'"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + theme().inputBorder"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[ngModel]="guestPhone()"
(ngModelChange)="guestPhone.set($event)"
/>
</div>
<!-- Submit button -->
<button
type="button"
class="syncagent-cc-guest-submit"
[style.padding]="'12px 20px'"
[style.font-size]="'14px'"
[style.font-weight]="600"
[style.color]="theme().userBubbleText"
[style.background-color]="theme().accent"
[style.border]="'none'"
[style.border-radius]="'8px'"
[style.cursor]="'pointer'"
[style.margin-top]="'8px'"
[style.outline]="'none'"
aria-label="Start chat"
(click)="handleGuestSubmit()"
>{{ guestForm?.buttonText || 'Start Chat' }}</button>
</div>
<!-- Chat Panel (shown when identified) -->
<ng-container *ngIf="chatService.isIdentified()">
<!-- Message list -->
<div
class="syncagent-cc-messages"
[style.flex]="1"
[style.overflow-y]="'auto'"
[style.padding]="'16px'"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.gap]="'12px'"
[style.background-color]="theme().background"
role="log"
aria-live="polite"
aria-label="Chat messages"
aria-relevant="additions"
>
<!-- Welcome message -->
<div
*ngIf="effectiveWelcomeMessage()"
class="syncagent-cc-welcome"
[style.padding]="'12px 16px'"
[style.border-radius]="'4px 16px 16px 16px'"
[style.background-color]="theme().assistantBubble"
[style.color]="theme().assistantBubbleText"
[style.font-size]="'14px'"
[style.line-height]="'1.6'"
[style.max-width]="'85%'"
[style.align-self]="'flex-start'"
aria-label="Welcome message"
>{{ effectiveWelcomeMessage() }}</div>
<!-- Messages -->
<ng-container *ngFor="let msg of displayMessages(); trackBy: trackMessage">
<!-- User message -->
<div
*ngIf="msg.role === 'user'"
class="syncagent-cc-msg-user"
[style.padding]="'10px 16px'"
[style.border-radius]="'16px 4px 16px 16px'"
[style.background-color]="theme().userBubble"
[style.color]="theme().userBubbleText"
[style.font-size]="'14px'"
[style.line-height]="'1.6'"
[style.max-width]="'80%'"
[style.align-self]="'flex-end'"
[style.word-break]="'break-word'"
>{{ msg.content }}</div>
<!-- Assistant message -->
<div
*ngIf="msg.role === 'assistant'"
class="syncagent-cc-msg-assistant"
[style.padding]="'10px 16px'"
[style.border-radius]="'4px 16px 16px 16px'"
[style.background-color]="theme().assistantBubble"
[style.color]="theme().assistantBubbleText"
[style.font-size]="'14px'"
[style.line-height]="'1.6'"
[style.max-width]="'85%'"
[style.align-self]="'flex-start'"
[style.word-break]="'break-word'"
>{{ msg.content }}</div>
<!-- System message -->
<div
*ngIf="msg.role === 'system'"
class="syncagent-cc-msg-system"
[style.padding]="'8px 14px'"
[style.border-radius]="'8px'"
[style.background-color]="darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'"
[style.border]="'1px solid ' + theme().border"
[style.color]="theme().textSecondary"
[style.font-size]="'12.5px'"
[style.line-height]="'1.5'"
[style.text-align]="'center'"
[style.align-self]="'center'"
[style.max-width]="'90%'"
[style.font-style]="'italic'"
role="status"
>
<span *ngIf="msg.systemType === 'escalation'">&#x1F504; </span>
<span *ngIf="msg.systemType === 'resolution'">&#x2713; </span>
{{ msg.content }}
</div>
</ng-container>
<!-- Loading indicator -->
<div
*ngIf="chatService.isLoading()"
class="syncagent-cc-loading"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'4px'"
[style.padding]="'10px 16px'"
[style.border-radius]="'4px 16px 16px 16px'"
[style.background-color]="theme().assistantBubble"
[style.align-self]="'flex-start'"
[style.max-width]="'80px'"
role="status"
aria-label="Loading response"
>
<span class="syncagent-cc-dot" [style.width]="'8px'" [style.height]="'8px'" [style.border-radius]="'50%'" [style.background-color]="theme().textSecondary" [style.opacity]="0.6"></span>
<span class="syncagent-cc-dot" [style.width]="'8px'" [style.height]="'8px'" [style.border-radius]="'50%'" [style.background-color]="theme().textSecondary" [style.opacity]="0.8"></span>
<span class="syncagent-cc-dot" [style.width]="'8px'" [style.height]="'8px'" [style.border-radius]="'50%'" [style.background-color]="theme().textSecondary" [style.opacity]="1"></span>
</div>
</div>
<!-- Escalation Banner -->
<div
*ngIf="chatService.isEscalated() && !chatService.isResolved()"
class="syncagent-cc-escalation"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'8px'"
[style.padding]="'10px 16px'"
[style.background-color]="darkMode ? (theme().accent + '1a') : (theme().accent + '12')"
[style.border-top]="'1px solid ' + (darkMode ? (theme().accent + '33') : (theme().accent + '28'))"
[style.border-bottom]="'1px solid ' + (darkMode ? (theme().accent + '33') : (theme().accent + '28'))"
[style.font-size]="'13px'"
[style.line-height]="'1.4'"
[style.color]="theme().text"
role="status"
aria-live="polite"
aria-label="Escalation status"
>
<span
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.width]="'24px'"
[style.height]="'24px'"
[style.border-radius]="'50%'"
[style.background-color]="theme().accent"
[style.flex-shrink]="0"
aria-hidden="true"
>
<svg [style.width]="'14px'" [style.height]="'14px'" [style.color]="theme().userBubbleText" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</span>
<span [style.font-weight]="500" [style.color]="theme().textSecondary">
You are now connected with a
<span [style.color]="theme().accent" [style.font-weight]="600"> human agent</span>.
They will assist you shortly.
</span>
</div>
<!-- Satisfaction Rating (shown when resolved) -->
<div
*ngIf="chatService.isResolved()"
class="syncagent-cc-rating"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.align-items]="'center'"
[style.gap]="'12px'"
[style.padding]="'16px'"
[style.background-color]="theme().surface"
[style.border-top]="'1px solid ' + theme().border"
role="group"
aria-label="Rate your experience"
>
<p
[style.font-size]="'14px'"
[style.font-weight]="500"
[style.color]="theme().text"
[style.margin]="0"
>{{ ratingSubmitted() ? 'Rating submitted' : 'How was your experience?' }}</p>
<div
class="syncagent-cc-stars"
[style.display]="'flex'"
[style.gap]="'4px'"
[style.align-items]="'center'"
role="radiogroup"
aria-label="Rating from 1 to 5 stars"
>
<button
*ngFor="let star of [1, 2, 3, 4, 5]"
type="button"
class="syncagent-cc-star"
role="radio"
[attr.aria-checked]="selectedRating() === star"
[attr.aria-label]="star + (star > 1 ? ' stars' : ' star')"
[style.background]="'none'"
[style.border]="'none'"
[style.padding]="'4px'"
[style.cursor]="ratingSubmitted() ? 'default' : 'pointer'"
[style.font-size]="'28px'"
[style.line-height]="1"
[style.color]="star <= (hoveredRating() || selectedRating() || 0) ? theme().accent : theme().border"
[style.outline]="'none'"
[style.border-radius]="'4px'"
[style.opacity]="ratingSubmitted() ? 0.8 : 1"
[disabled]="ratingSubmitted()"
(click)="handleRating(star)"
(mouseenter)="hoveredRating.set(star)"
(mouseleave)="hoveredRating.set(0)"
(keydown)="handleRatingKeydown($event, star)"
>{{ star <= (hoveredRating() || selectedRating() || 0) ? '\u2605' : '\u2606' }}</button>
</div>
<p
*ngIf="ratingSubmitted()"
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().textSecondary"
[style.margin]="0"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'6px'"
role="status"
aria-live="polite"
>
<span aria-hidden="true">\u2713</span>
Thank you for your feedback!
</p>
</div>
<!-- Message Input -->
<form
class="syncagent-cc-input"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'8px'"
[style.padding]="'12px 16px'"
[style.border-top]="'1px solid ' + theme().border"
[style.background-color]="theme().surface"
[style.border-radius]="'0 0 12px 12px'"
[style.flex-shrink]="0"
role="form"
aria-label="Message input"
(submit)="handleSend($event)"
>
<input
#messageInput
type="text"
class="syncagent-cc-text-input"
[placeholder]="placeholder"
[style.flex]="1"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.line-height]="'1.5'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + theme().inputBorder"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[style.min-width]="0"
[disabled]="chatService.isLoading()"
[ngModel]="inputValue()"
(ngModelChange)="inputValue.set($event)"
(keydown.enter)="handleSend($event)"
aria-label="Type your message"
[attr.aria-disabled]="chatService.isLoading()"
/>
<button
type="submit"
class="syncagent-cc-send-btn"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.width]="'36px'"
[style.height]="'36px'"
[style.border-radius]="'8px'"
[style.border]="'none'"
[style.background-color]="canSend() ? theme().accent : theme().border"
[style.color]="canSend() ? theme().userBubbleText : theme().textSecondary"
[style.cursor]="canSend() ? 'pointer' : 'not-allowed'"
[style.flex-shrink]="0"
[style.outline]="'none'"
[style.opacity]="chatService.isLoading() ? 0.6 : 1"
[disabled]="!canSend()"
aria-label="Send message"
[attr.aria-disabled]="!canSend()"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
</ng-container>
</div>
`,
styles: [
`
/* \u2500\u2500\u2500 Focus Indicators (WCAG 2.1 \u2014 2px outline, 3:1 contrast) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
.syncagent-cc-focusable:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
}
.syncagent-cc-toggle-btn:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 4px rgba(99, 102, 241, 0.2);
}
.syncagent-cc-input:focus-visible {
border-color: var(--syncagent-accent, #6366f1);
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: -2px;
}
.syncagent-cc-send-btn:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
}
.syncagent-cc-close-btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.syncagent-cc-star:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
border-radius: 4px;
}
.syncagent-cc-guest-submit:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
}
/* \u2500\u2500\u2500 Loading animation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
@keyframes syncagent-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.syncagent-cc-loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: syncagent-dot-bounce 1.4s infinite ease-in-out both;
}
`
]
})
], SyncAgentCustomerChatComponent);
// src/index.ts
var import_js4 = require("@syncagent/js");
var import_js5 = require("@syncagent/js");
var import_js6 = require("@syncagent/js");
// Annotate the CommonJS export names for ESM import in node:

@@ -508,2 +1607,3 @@ 0 && (module.exports = {

SyncAgentClient,
SyncAgentCustomerChatComponent,
SyncAgentService,

@@ -510,0 +1610,0 @@ detectPageContext,

@@ -471,2 +471,1102 @@ var __defProp = Object.defineProperty;

// src/customer-chat.component.ts
import {
Component,
Input,
Output,
EventEmitter,
ViewEncapsulation,
signal as signal5,
computed as computed2,
effect,
ViewChild
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import {
computeTheme
} from "@syncagent/js";
// src/pusher.service.ts
import { Injectable as Injectable4, signal as signal4 } from "@angular/core";
import { Subject as Subject3 } from "rxjs";
var POLLING_INTERVAL_MS = 5e3;
var PusherService = class {
constructor() {
this.pusher = null;
this.channel = null;
this.pollingInterval = null;
this.existingIds = /* @__PURE__ */ new Set();
this.cancelled = false;
/** Whether Pusher is currently connected */
this.isConnected = signal4(false);
/** Emits new (deduplicated) messages received via Pusher */
this.newMessage$ = new Subject3();
}
/**
* Connect to a Pusher channel for real-time messages.
*
* @param pusherKey - Pusher app key
* @param cluster - Pusher cluster (e.g. "us2")
* @param conversationId - The conversation channel to subscribe to
* @param existingIds - Set of existing message IDs for deduplication
*/
connect(pusherKey, cluster, conversationId, existingIds) {
this.disconnect();
this.existingIds = new Set(existingIds);
this.cancelled = false;
const channelName = `conversation-${conversationId}`;
this.initPusher(pusherKey, cluster, channelName);
}
/**
* Disconnect from Pusher and stop polling.
* Cleans up all subscriptions and intervals.
*/
disconnect() {
this.cancelled = true;
this.stopPolling();
if (this.channel) {
this.channel.unbind_all();
this.channel = null;
}
if (this.pusher) {
this.pusher.disconnect();
this.pusher = null;
}
this.isConnected.set(false);
}
ngOnDestroy() {
this.disconnect();
this.newMessage$.complete();
}
/**
* Update the set of existing message IDs for deduplication.
* Call this when new messages arrive from other sources (e.g. API responses).
*/
updateExistingIds(ids) {
this.existingIds = new Set(ids);
}
startPolling() {
if (this.pollingInterval !== null) return;
this.pollingInterval = setInterval(() => {
}, POLLING_INTERVAL_MS);
}
stopPolling() {
if (this.pollingInterval !== null) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
async initPusher(pusherKey, cluster, channelName) {
try {
const PusherModule = await import("pusher-js");
const Pusher = PusherModule.default || PusherModule;
if (this.cancelled) return;
const pusher = new Pusher(pusherKey, { cluster });
this.pusher = pusher;
pusher.connection.bind("connected", () => {
if (this.cancelled) return;
this.isConnected.set(true);
this.stopPolling();
});
pusher.connection.bind("disconnected", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
pusher.connection.bind("error", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
pusher.connection.bind("failed", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
const channel = pusher.subscribe(channelName);
this.channel = channel;
channel.bind("new-message", (data) => {
if (this.cancelled) return;
if (data.id && this.existingIds.has(data.id)) {
return;
}
this.existingIds.add(data.id);
this.newMessage$.next(data);
});
channel.bind("pusher:subscription_error", () => {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
});
} catch {
if (this.cancelled) return;
this.isConnected.set(false);
this.startPolling();
}
}
};
PusherService = __decorateClass([
Injectable4()
], PusherService);
// src/customer-chat.component.ts
var SyncAgentCustomerChatComponent = class {
// ─── Constructor ──────────────────────────────────────────────────────────────
constructor(chatService, pusherService) {
this.chatService = chatService;
this.pusherService = pusherService;
this.mode = "floating";
this.position = "bottom-right";
this.defaultOpen = false;
this.title = "Customer Support";
this.subtitle = "How can we help you?";
this.placeholder = "Type your message...";
this.darkMode = false;
this.escalated = new EventEmitter();
this.resolved = new EventEmitter();
this.guestIdentified = new EventEmitter();
// ─── Internal State (Signals) ─────────────────────────────────────────────────
/** Whether the chat panel is open (floating mode only) */
this.isOpen = signal5(false);
/** Messages received via Pusher (human agent messages during escalation) */
this.pusherMessages = signal5([]);
/** Whether a satisfaction rating has been submitted */
this.ratingSubmitted = signal5(false);
/** Current message input value */
this.inputValue = signal5("");
/** Guest form field values */
this.guestName = signal5("");
this.guestEmail = signal5("");
this.guestPhone = signal5("");
/** Guest form validation errors */
this.guestErrors = signal5({});
/** Hovered star rating (0 = none) */
this.hoveredRating = signal5(0);
/** Selected star rating (0 = none) */
this.selectedRating = signal5(0);
/** Whether the escalation callback has been fired (prevents duplicates) */
this.escalationFired = false;
/** Track previous isIdentified state for focus management */
this.wasIdentified = null;
// ─── Computed Values ──────────────────────────────────────────────────────────
/** Computed theme colors from accent color and dark mode flag */
this.theme = computed2(
() => computeTheme(this.accentColor, this.darkMode)
);
/** Computed: whether the send button should be enabled */
this.canSend = computed2(
() => this.inputValue().trim().length > 0 && !this.chatService.isLoading()
);
/** Combined display messages from service messages + Pusher messages */
this.displayMessages = computed2(() => {
const serviceMessages = this.chatService.messages().map(
(msg, idx) => ({
id: `msg-${idx}`,
role: msg.role,
content: msg.content,
timestamp: /* @__PURE__ */ new Date()
})
);
const combined = [...serviceMessages, ...this.pusherMessages()];
const error = this.chatService.error();
if (error) {
combined.push({
id: "error-inline",
role: "system",
content: error.message || "Something went wrong. Please try again.",
timestamp: /* @__PURE__ */ new Date(),
systemType: "info"
});
}
return combined;
});
/** Effective welcome message: prop takes precedence over API-returned */
this.effectiveWelcomeMessage = computed2(
() => this.welcomeMessage ?? this.chatService.welcomeMessage()
);
/** Set of existing message IDs for Pusher deduplication */
this.existingMessageIds = computed2(() => {
const ids = /* @__PURE__ */ new Set();
this.chatService.messages().forEach((_msg, idx) => ids.add(`msg-${idx}`));
this.pusherMessages().forEach((msg) => ids.add(msg.id));
return ids;
});
// ─── Effects ──────────────────────────────────────────────────────────────────
/** Effect: fire escalated output event exactly once per escalation */
this.escalationEffect = effect(() => {
const isEscalated = this.chatService.isEscalated();
if (isEscalated && !this.escalationFired) {
this.escalationFired = true;
this.escalated.emit();
if (this.pusherKey) {
this.pusherService.connect(
this.pusherKey,
this.pusherCluster || "us2",
this.chatService.conversationId() || "",
this.existingMessageIds()
);
}
}
if (!isEscalated && this.escalationFired) {
this.escalationFired = false;
}
});
/** Effect: fire resolved output event and disconnect Pusher */
this.resolvedEffect = effect(() => {
const isResolved = this.chatService.isResolved();
const conversationId = this.chatService.conversationId();
if (isResolved && conversationId) {
this.resolved.emit(conversationId);
this.pusherService.disconnect();
this.pusherMessages.set([]);
}
});
/** Effect: focus management on guest form → chat transition */
this.focusEffect = effect(() => {
const isIdentified = this.chatService.isIdentified();
if (this.wasIdentified === null) {
this.wasIdentified = isIdentified;
return;
}
if (!this.wasIdentified && isIdentified) {
setTimeout(() => {
this.messageInputRef?.nativeElement?.focus();
}, 50);
}
this.wasIdentified = isIdentified;
});
/** Effect: emit guestIdentified when guest identity changes */
this.guestIdentifiedEffect = effect(() => {
const identity = this.chatService.guestIdentity();
if (identity) {
this.guestIdentified.emit(identity);
}
});
this.pusherService.newMessage$.subscribe((message) => {
const displayMsg = {
id: message.id,
role: "assistant",
content: message.content || "",
timestamp: message.timestamp ? new Date(message.timestamp) : /* @__PURE__ */ new Date()
};
this.pusherMessages.update((prev) => [...prev, displayMsg]);
});
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
ngOnInit() {
this.isOpen.set(this.mode === "inline" ? true : this.defaultOpen);
const resolvedConfig = this.resolveConfig();
this.chatService.configure({
...resolvedConfig,
externalUserId: this.externalUserId,
onEscalated: () => {
},
onResolved: () => {
},
onGuestIdentified: () => {
}
});
}
ngOnDestroy() {
this.pusherService.disconnect();
}
// ─── Public Methods (for template) ────────────────────────────────────────────
/** Toggle the chat panel open/closed (floating mode) */
togglePanel() {
this.isOpen.update((v) => !v);
}
/** Close the chat panel (floating mode) */
closePanel() {
this.isOpen.set(false);
}
/** Send a message */
async sendMessage() {
const content = this.inputValue().trim();
if (!content) return;
this.inputValue.set("");
await this.chatService.sendMessage(content, this.metadata);
}
/** Submit guest identification form */
submitGuestForm() {
this.chatService.identifyGuest({
name: this.guestName(),
email: this.guestEmail(),
phone: this.guestPhone() || void 0
});
}
/** Submit a satisfaction rating */
async rateConversation(rating) {
if (this.ratingSubmitted()) return;
await this.chatService.rateConversation(rating);
this.ratingSubmitted.set(true);
}
/** Handle guest form submission with validation */
handleGuestSubmit() {
const errors = {};
const name = this.guestName().trim();
const email = this.guestEmail().trim();
if (!name) {
errors["name"] = "Name is required";
}
if (!email) {
errors["email"] = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors["email"] = "Please enter a valid email address";
}
this.guestErrors.set(errors);
if (Object.keys(errors).length === 0) {
this.submitGuestForm();
}
}
/** Handle send message from form */
handleSend(event) {
event.preventDefault();
this.sendMessage();
}
/** Handle rating star click */
async handleRating(star) {
if (this.ratingSubmitted()) return;
this.selectedRating.set(star);
await this.rateConversation(star);
}
/** Handle keyboard navigation for rating stars */
handleRatingKeydown(event, star) {
if (this.ratingSubmitted()) return;
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
const next = Math.min(star + 1, 5);
const buttons = event.target.parentElement?.querySelectorAll("button");
if (buttons && buttons[next - 1]) {
buttons[next - 1].focus();
}
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
const prev = Math.max(star - 1, 1);
const buttons = event.target.parentElement?.querySelectorAll("button");
if (buttons && buttons[prev - 1]) {
buttons[prev - 1].focus();
}
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.handleRating(star);
}
}
/** TrackBy function for message list */
trackMessage(_index, msg) {
return msg.id;
}
// ─── Private Methods ──────────────────────────────────────────────────────────
/**
* Resolves the configuration for the customer chat component.
*
* Priority order:
* 1. `config` input — use directly
* 2. `apiKey` + `connectionString` individual inputs — build config
* 3. Throw error if nothing provided
*/
resolveConfig() {
if (this.config) {
return this.config;
}
if (this.apiKey !== void 0 || this.connectionString !== void 0) {
if (!this.apiKey || this.apiKey.trim() === "") {
throw new Error(
"SyncAgentCustomerChat: apiKey is required and cannot be empty"
);
}
if (!this.connectionString || this.connectionString.trim() === "") {
throw new Error(
"SyncAgentCustomerChat: connectionString is required and cannot be empty"
);
}
return {
apiKey: this.apiKey,
connectionString: this.connectionString,
customerMode: true,
externalUserId: this.externalUserId
};
}
throw new Error(
"SyncAgentCustomerChat: Either provide a `config` input or both `apiKey` and `connectionString` inputs"
);
}
};
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "config", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "apiKey", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "connectionString", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "externalUserId", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "mode", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "position", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "defaultOpen", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "title", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "subtitle", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "placeholder", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "welcomeMessage", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "accentColor", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "darkMode", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "className", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "guestForm", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "pusherKey", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "pusherCluster", 2);
__decorateClass([
Input()
], SyncAgentCustomerChatComponent.prototype, "metadata", 2);
__decorateClass([
Output()
], SyncAgentCustomerChatComponent.prototype, "escalated", 2);
__decorateClass([
Output()
], SyncAgentCustomerChatComponent.prototype, "resolved", 2);
__decorateClass([
Output()
], SyncAgentCustomerChatComponent.prototype, "guestIdentified", 2);
__decorateClass([
ViewChild("messageInput")
], SyncAgentCustomerChatComponent.prototype, "messageInputRef", 2);
SyncAgentCustomerChatComponent = __decorateClass([
Component({
selector: "syncagent-customer-chat",
standalone: true,
imports: [CommonModule, FormsModule],
encapsulation: ViewEncapsulation.None,
providers: [CustomerChatService, PusherService],
template: `
<!-- Floating mode: toggle button -->
<button
*ngIf="mode === 'floating'"
type="button"
class="syncagent-cc-toggle"
[style.position]="'fixed'"
[style.bottom]="'20px'"
[style.right]="position === 'bottom-right' ? '20px' : 'auto'"
[style.left]="position === 'bottom-left' ? '20px' : 'auto'"
[style.width]="'56px'"
[style.height]="'56px'"
[style.border-radius]="'50%'"
[style.background-color]="theme().accent"
[style.color]="theme().userBubbleText"
[style.border]="'none'"
[style.cursor]="'pointer'"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.box-shadow]="'0 4px 12px rgba(0, 0, 0, 0.15)'"
[style.z-index]="9999"
[style.outline]="'none'"
[attr.aria-label]="isOpen() ? 'Close chat' : 'Open chat'"
[attr.aria-expanded]="isOpen()"
(click)="togglePanel()"
>
<!-- Chat icon when closed -->
<svg *ngIf="!isOpen()" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<!-- Close icon when open -->
<svg *ngIf="isOpen()" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
<!-- Chat panel container -->
<div
*ngIf="mode === 'inline' || isOpen()"
class="syncagent-cc-container"
[class]="className || ''"
[style.position]="mode === 'floating' ? 'fixed' : 'relative'"
[style.bottom]="mode === 'floating' ? '88px' : 'auto'"
[style.right]="mode === 'floating' && position === 'bottom-right' ? '20px' : 'auto'"
[style.left]="mode === 'floating' && position === 'bottom-left' ? '20px' : 'auto'"
[style.width]="mode === 'floating' ? '380px' : '100%'"
[style.height]="mode === 'floating' ? '520px' : '100%'"
[style.border-radius]="'12px'"
[style.box-shadow]="mode === 'floating' ? '0 8px 32px rgba(0, 0, 0, 0.12)' : 'none'"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.overflow]="'hidden'"
[style.background-color]="theme().background"
[style.border]="'1px solid ' + theme().border"
[style.z-index]="mode === 'floating' ? 9998 : 'auto'"
[style.font-family]="'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'"
role="region"
aria-label="Customer support chat"
>
<!-- Header -->
<div
class="syncagent-cc-header"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'space-between'"
[style.padding]="'16px 20px'"
[style.background-color]="theme().accent"
[style.border-radius]="'12px 12px 0 0'"
[style.flex-shrink]="0"
>
<div [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'2px'" [style.min-width]="0" [style.flex]="1">
<h2
class="syncagent-cc-title"
[style.font-size]="'15px'"
[style.font-weight]="700"
[style.color]="theme().userBubbleText"
[style.margin]="0"
[style.line-height]="'1.3'"
[style.overflow]="'hidden'"
[style.text-overflow]="'ellipsis'"
[style.white-space]="'nowrap'"
>{{ title }}</h2>
<p
class="syncagent-cc-subtitle"
[style.font-size]="'12.5px'"
[style.font-weight]="400"
[style.color]="theme().userBubbleText"
[style.opacity]="0.85"
[style.margin]="0"
[style.line-height]="'1.4'"
[style.overflow]="'hidden'"
[style.text-overflow]="'ellipsis'"
[style.white-space]="'nowrap'"
>{{ subtitle }}</p>
</div>
<!-- Close button (floating mode only) -->
<button
*ngIf="mode === 'floating'"
type="button"
class="syncagent-cc-close-btn"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.width]="'28px'"
[style.height]="'28px'"
[style.border-radius]="'50%'"
[style.border]="'none'"
[style.background]="'rgba(255, 255, 255, 0.2)'"
[style.color]="theme().userBubbleText"
[style.cursor]="'pointer'"
[style.font-size]="'16px'"
[style.line-height]="1"
[style.flex-shrink]="0"
[style.margin-left]="'12px'"
[style.outline]="'none'"
aria-label="Close chat"
(click)="closePanel()"
>\u2715</button>
</div>
<!-- Guest Form (shown when not identified) -->
<div
*ngIf="!chatService.isIdentified()"
class="syncagent-cc-guest-form"
[style.flex]="1"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.padding]="'24px 20px'"
[style.gap]="'16px'"
[style.overflow-y]="'auto'"
[style.background-color]="theme().background"
>
<div [style.text-align]="'center'" [style.margin-bottom]="'8px'">
<h3
[style.font-size]="'16px'"
[style.font-weight]="600"
[style.color]="theme().text"
[style.margin]="'0 0 4px 0'"
>{{ guestForm?.title || 'Welcome!' }}</h3>
<p
[style.font-size]="'13px'"
[style.color]="theme().textSecondary"
[style.margin]="0"
>{{ guestForm?.subtitle || 'Please introduce yourself to get started.' }}</p>
</div>
<!-- Name field -->
<div class="syncagent-cc-field" [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'4px'">
<label
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().text"
for="syncagent-guest-name"
>Name *</label>
<input
id="syncagent-guest-name"
type="text"
[placeholder]="guestForm?.namePlaceholder || 'Your name'"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + (guestErrors()['name'] ? '#ef4444' : theme().inputBorder)"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[ngModel]="guestName()"
(ngModelChange)="guestName.set($event)"
aria-required="true"
[attr.aria-invalid]="!!guestErrors()['name']"
[attr.aria-describedby]="guestErrors()['name'] ? 'syncagent-name-error' : null"
/>
<span
*ngIf="guestErrors()['name']"
id="syncagent-name-error"
[style.font-size]="'12px'"
[style.color]="'#ef4444'"
role="alert"
>{{ guestErrors()['name'] }}</span>
</div>
<!-- Email field -->
<div class="syncagent-cc-field" [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'4px'">
<label
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().text"
for="syncagent-guest-email"
>Email *</label>
<input
id="syncagent-guest-email"
type="email"
[placeholder]="guestForm?.emailPlaceholder || 'your@email.com'"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + (guestErrors()['email'] ? '#ef4444' : theme().inputBorder)"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[ngModel]="guestEmail()"
(ngModelChange)="guestEmail.set($event)"
aria-required="true"
[attr.aria-invalid]="!!guestErrors()['email']"
[attr.aria-describedby]="guestErrors()['email'] ? 'syncagent-email-error' : null"
/>
<span
*ngIf="guestErrors()['email']"
id="syncagent-email-error"
[style.font-size]="'12px'"
[style.color]="'#ef4444'"
role="alert"
>{{ guestErrors()['email'] }}</span>
</div>
<!-- Phone field -->
<div class="syncagent-cc-field" [style.display]="'flex'" [style.flex-direction]="'column'" [style.gap]="'4px'">
<label
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().text"
for="syncagent-guest-phone"
>Phone</label>
<input
id="syncagent-guest-phone"
type="tel"
[placeholder]="guestForm?.phonePlaceholder || 'Phone number (optional)'"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + theme().inputBorder"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[ngModel]="guestPhone()"
(ngModelChange)="guestPhone.set($event)"
/>
</div>
<!-- Submit button -->
<button
type="button"
class="syncagent-cc-guest-submit"
[style.padding]="'12px 20px'"
[style.font-size]="'14px'"
[style.font-weight]="600"
[style.color]="theme().userBubbleText"
[style.background-color]="theme().accent"
[style.border]="'none'"
[style.border-radius]="'8px'"
[style.cursor]="'pointer'"
[style.margin-top]="'8px'"
[style.outline]="'none'"
aria-label="Start chat"
(click)="handleGuestSubmit()"
>{{ guestForm?.buttonText || 'Start Chat' }}</button>
</div>
<!-- Chat Panel (shown when identified) -->
<ng-container *ngIf="chatService.isIdentified()">
<!-- Message list -->
<div
class="syncagent-cc-messages"
[style.flex]="1"
[style.overflow-y]="'auto'"
[style.padding]="'16px'"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.gap]="'12px'"
[style.background-color]="theme().background"
role="log"
aria-live="polite"
aria-label="Chat messages"
aria-relevant="additions"
>
<!-- Welcome message -->
<div
*ngIf="effectiveWelcomeMessage()"
class="syncagent-cc-welcome"
[style.padding]="'12px 16px'"
[style.border-radius]="'4px 16px 16px 16px'"
[style.background-color]="theme().assistantBubble"
[style.color]="theme().assistantBubbleText"
[style.font-size]="'14px'"
[style.line-height]="'1.6'"
[style.max-width]="'85%'"
[style.align-self]="'flex-start'"
aria-label="Welcome message"
>{{ effectiveWelcomeMessage() }}</div>
<!-- Messages -->
<ng-container *ngFor="let msg of displayMessages(); trackBy: trackMessage">
<!-- User message -->
<div
*ngIf="msg.role === 'user'"
class="syncagent-cc-msg-user"
[style.padding]="'10px 16px'"
[style.border-radius]="'16px 4px 16px 16px'"
[style.background-color]="theme().userBubble"
[style.color]="theme().userBubbleText"
[style.font-size]="'14px'"
[style.line-height]="'1.6'"
[style.max-width]="'80%'"
[style.align-self]="'flex-end'"
[style.word-break]="'break-word'"
>{{ msg.content }}</div>
<!-- Assistant message -->
<div
*ngIf="msg.role === 'assistant'"
class="syncagent-cc-msg-assistant"
[style.padding]="'10px 16px'"
[style.border-radius]="'4px 16px 16px 16px'"
[style.background-color]="theme().assistantBubble"
[style.color]="theme().assistantBubbleText"
[style.font-size]="'14px'"
[style.line-height]="'1.6'"
[style.max-width]="'85%'"
[style.align-self]="'flex-start'"
[style.word-break]="'break-word'"
>{{ msg.content }}</div>
<!-- System message -->
<div
*ngIf="msg.role === 'system'"
class="syncagent-cc-msg-system"
[style.padding]="'8px 14px'"
[style.border-radius]="'8px'"
[style.background-color]="darkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'"
[style.border]="'1px solid ' + theme().border"
[style.color]="theme().textSecondary"
[style.font-size]="'12.5px'"
[style.line-height]="'1.5'"
[style.text-align]="'center'"
[style.align-self]="'center'"
[style.max-width]="'90%'"
[style.font-style]="'italic'"
role="status"
>
<span *ngIf="msg.systemType === 'escalation'">&#x1F504; </span>
<span *ngIf="msg.systemType === 'resolution'">&#x2713; </span>
{{ msg.content }}
</div>
</ng-container>
<!-- Loading indicator -->
<div
*ngIf="chatService.isLoading()"
class="syncagent-cc-loading"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'4px'"
[style.padding]="'10px 16px'"
[style.border-radius]="'4px 16px 16px 16px'"
[style.background-color]="theme().assistantBubble"
[style.align-self]="'flex-start'"
[style.max-width]="'80px'"
role="status"
aria-label="Loading response"
>
<span class="syncagent-cc-dot" [style.width]="'8px'" [style.height]="'8px'" [style.border-radius]="'50%'" [style.background-color]="theme().textSecondary" [style.opacity]="0.6"></span>
<span class="syncagent-cc-dot" [style.width]="'8px'" [style.height]="'8px'" [style.border-radius]="'50%'" [style.background-color]="theme().textSecondary" [style.opacity]="0.8"></span>
<span class="syncagent-cc-dot" [style.width]="'8px'" [style.height]="'8px'" [style.border-radius]="'50%'" [style.background-color]="theme().textSecondary" [style.opacity]="1"></span>
</div>
</div>
<!-- Escalation Banner -->
<div
*ngIf="chatService.isEscalated() && !chatService.isResolved()"
class="syncagent-cc-escalation"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'8px'"
[style.padding]="'10px 16px'"
[style.background-color]="darkMode ? (theme().accent + '1a') : (theme().accent + '12')"
[style.border-top]="'1px solid ' + (darkMode ? (theme().accent + '33') : (theme().accent + '28'))"
[style.border-bottom]="'1px solid ' + (darkMode ? (theme().accent + '33') : (theme().accent + '28'))"
[style.font-size]="'13px'"
[style.line-height]="'1.4'"
[style.color]="theme().text"
role="status"
aria-live="polite"
aria-label="Escalation status"
>
<span
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.width]="'24px'"
[style.height]="'24px'"
[style.border-radius]="'50%'"
[style.background-color]="theme().accent"
[style.flex-shrink]="0"
aria-hidden="true"
>
<svg [style.width]="'14px'" [style.height]="'14px'" [style.color]="theme().userBubbleText" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</span>
<span [style.font-weight]="500" [style.color]="theme().textSecondary">
You are now connected with a
<span [style.color]="theme().accent" [style.font-weight]="600"> human agent</span>.
They will assist you shortly.
</span>
</div>
<!-- Satisfaction Rating (shown when resolved) -->
<div
*ngIf="chatService.isResolved()"
class="syncagent-cc-rating"
[style.display]="'flex'"
[style.flex-direction]="'column'"
[style.align-items]="'center'"
[style.gap]="'12px'"
[style.padding]="'16px'"
[style.background-color]="theme().surface"
[style.border-top]="'1px solid ' + theme().border"
role="group"
aria-label="Rate your experience"
>
<p
[style.font-size]="'14px'"
[style.font-weight]="500"
[style.color]="theme().text"
[style.margin]="0"
>{{ ratingSubmitted() ? 'Rating submitted' : 'How was your experience?' }}</p>
<div
class="syncagent-cc-stars"
[style.display]="'flex'"
[style.gap]="'4px'"
[style.align-items]="'center'"
role="radiogroup"
aria-label="Rating from 1 to 5 stars"
>
<button
*ngFor="let star of [1, 2, 3, 4, 5]"
type="button"
class="syncagent-cc-star"
role="radio"
[attr.aria-checked]="selectedRating() === star"
[attr.aria-label]="star + (star > 1 ? ' stars' : ' star')"
[style.background]="'none'"
[style.border]="'none'"
[style.padding]="'4px'"
[style.cursor]="ratingSubmitted() ? 'default' : 'pointer'"
[style.font-size]="'28px'"
[style.line-height]="1"
[style.color]="star <= (hoveredRating() || selectedRating() || 0) ? theme().accent : theme().border"
[style.outline]="'none'"
[style.border-radius]="'4px'"
[style.opacity]="ratingSubmitted() ? 0.8 : 1"
[disabled]="ratingSubmitted()"
(click)="handleRating(star)"
(mouseenter)="hoveredRating.set(star)"
(mouseleave)="hoveredRating.set(0)"
(keydown)="handleRatingKeydown($event, star)"
>{{ star <= (hoveredRating() || selectedRating() || 0) ? '\u2605' : '\u2606' }}</button>
</div>
<p
*ngIf="ratingSubmitted()"
[style.font-size]="'13px'"
[style.font-weight]="500"
[style.color]="theme().textSecondary"
[style.margin]="0"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'6px'"
role="status"
aria-live="polite"
>
<span aria-hidden="true">\u2713</span>
Thank you for your feedback!
</p>
</div>
<!-- Message Input -->
<form
class="syncagent-cc-input"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.gap]="'8px'"
[style.padding]="'12px 16px'"
[style.border-top]="'1px solid ' + theme().border"
[style.background-color]="theme().surface"
[style.border-radius]="'0 0 12px 12px'"
[style.flex-shrink]="0"
role="form"
aria-label="Message input"
(submit)="handleSend($event)"
>
<input
#messageInput
type="text"
class="syncagent-cc-text-input"
[placeholder]="placeholder"
[style.flex]="1"
[style.padding]="'10px 14px'"
[style.font-size]="'14px'"
[style.line-height]="'1.5'"
[style.color]="theme().text"
[style.background-color]="theme().inputBackground"
[style.border]="'1px solid ' + theme().inputBorder"
[style.border-radius]="'8px'"
[style.outline]="'none'"
[style.min-width]="0"
[disabled]="chatService.isLoading()"
[ngModel]="inputValue()"
(ngModelChange)="inputValue.set($event)"
(keydown.enter)="handleSend($event)"
aria-label="Type your message"
[attr.aria-disabled]="chatService.isLoading()"
/>
<button
type="submit"
class="syncagent-cc-send-btn"
[style.display]="'flex'"
[style.align-items]="'center'"
[style.justify-content]="'center'"
[style.width]="'36px'"
[style.height]="'36px'"
[style.border-radius]="'8px'"
[style.border]="'none'"
[style.background-color]="canSend() ? theme().accent : theme().border"
[style.color]="canSend() ? theme().userBubbleText : theme().textSecondary"
[style.cursor]="canSend() ? 'pointer' : 'not-allowed'"
[style.flex-shrink]="0"
[style.outline]="'none'"
[style.opacity]="chatService.isLoading() ? 0.6 : 1"
[disabled]="!canSend()"
aria-label="Send message"
[attr.aria-disabled]="!canSend()"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
</ng-container>
</div>
`,
styles: [
`
/* \u2500\u2500\u2500 Focus Indicators (WCAG 2.1 \u2014 2px outline, 3:1 contrast) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
.syncagent-cc-focusable:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
}
.syncagent-cc-toggle-btn:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 4px rgba(99, 102, 241, 0.2);
}
.syncagent-cc-input:focus-visible {
border-color: var(--syncagent-accent, #6366f1);
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: -2px;
}
.syncagent-cc-send-btn:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
}
.syncagent-cc-close-btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.syncagent-cc-star:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
border-radius: 4px;
}
.syncagent-cc-guest-submit:focus-visible {
outline: 2px solid var(--syncagent-accent, #6366f1);
outline-offset: 2px;
}
/* \u2500\u2500\u2500 Loading animation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
@keyframes syncagent-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.syncagent-cc-loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: syncagent-dot-bounce 1.4s infinite ease-in-out both;
}
`
]
})
], SyncAgentCustomerChatComponent);
// src/index.ts

@@ -480,2 +1580,3 @@ import { SyncAgentClient as SyncAgentClient4, detectPageContext } from "@syncagent/js";

SyncAgentClient4 as SyncAgentClient,
SyncAgentCustomerChatComponent,
SyncAgentService,

@@ -482,0 +1583,0 @@ detectPageContext,

{
"name": "@syncagent/angular",
"version": "0.4.0",
"version": "0.5.0",
"description": "SyncAgent Angular SDK — service and component for AI database chat",

@@ -31,5 +31,12 @@ "homepage": "https://syncagentdev.vercel.app/docs",

"@angular/common": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"rxjs": ">=7.0.0",
"@syncagent/js": ">=0.2.0"
"@syncagent/js": ">=0.2.0",
"pusher-js": ">=8.0.0"
},
"peerDependenciesMeta": {
"pusher-js": {
"optional": true
}
},
"devDependencies": {

@@ -39,2 +46,3 @@ "@syncagent/js": "file:../js",

"@angular/common": "^18.0.0",
"@angular/forms": "^18.0.0",
"rxjs": "^7.8.0",

@@ -41,0 +49,0 @@ "tsup": "^8.4.0",

+288
-0

@@ -725,2 +725,290 @@ # @syncagent/angular

## Customer Chat Component
The `<syncagent-customer-chat>` component is a pre-built, drop-in customer support chat widget. It handles the full conversation lifecycle — guest identification, AI messaging, escalation to human agents via Pusher, and satisfaction rating — with zero custom UI required.
```typescript
import { SyncAgentCustomerChatComponent } from "@syncagent/angular";
@Component({
imports: [SyncAgentCustomerChatComponent],
template: `
<syncagent-customer-chat
[apiKey]="'sa_your_api_key'"
[connectionString]="'your_connection_string'"
(escalated)="onEscalated()"
(resolved)="onResolved($event)"
/>
`,
})
export class AppComponent {
onEscalated() { console.log("Escalated to human agent"); }
onResolved(id: string) { console.log("Resolved:", id); }
}
```
### Inputs
| Input | Type | Default | Description |
|-------|------|---------|-------------|
| `config` | `SyncAgentConfig` | — | Pre-built config object. When provided, `apiKey`/`connectionString` are ignored. |
| `apiKey` | `string` | — | API key for authentication (ignored if `config` provided) |
| `connectionString` | `string` | — | Database connection string (ignored if `config` provided) |
| `externalUserId` | `string` | — | Authenticated user ID — skips guest form when provided |
| `mode` | `"floating" \| "inline"` | `"floating"` | Floating toggle button or embedded inline panel |
| `position` | `"bottom-right" \| "bottom-left"` | `"bottom-right"` | Position of the floating widget (floating mode only) |
| `defaultOpen` | `boolean` | `false` | Whether the floating panel starts open (floating mode only) |
| `title` | `string` | `"Customer Support"` | Header title text (max 100 chars) |
| `subtitle` | `string` | `"How can we help you?"` | Header subtitle text (max 200 chars) |
| `placeholder` | `string` | `"Type your message..."` | Input placeholder text (max 150 chars) |
| `welcomeMessage` | `string` | — | Initial welcome message displayed before any interaction |
| `accentColor` | `string` | `"#6366f1"` | Primary accent color (hex, rgb, or hsl) |
| `darkMode` | `boolean` | `false` | Enable dark mode color scheme |
| `className` | `string` | — | Additional CSS class on the root container |
| `guestForm` | `GuestFormConfig` | — | Custom guest form configuration (title, subtitle, placeholders, button text) |
| `pusherKey` | `string` | — | Pusher app key for real-time human agent messages |
| `pusherCluster` | `string` | `"us2"` | Pusher cluster |
| `metadata` | `Record<string, any>` | — | Custom metadata attached to conversations |
### Outputs
| Output | Payload | Description |
|--------|---------|-------------|
| `escalated` | `void` | Emitted when the conversation is escalated to a human agent |
| `resolved` | `string` | Emitted when the conversation is resolved (payload is the conversation ID) |
| `guestIdentified` | `GuestIdentity` | Emitted after guest form submission with the guest identity object |
### Code Examples
**Basic template usage:**
```typescript
@Component({
imports: [SyncAgentCustomerChatComponent],
template: `
<syncagent-customer-chat
[apiKey]="'sa_your_api_key'"
[connectionString]="'postgresql://user:pass@host:5432/db'"
[externalUserId]="currentUser.id"
/>
`,
})
export class SupportPageComponent {
currentUser = { id: "user_123" };
}
```
**Using a config object:**
```typescript
import { SyncAgentConfig } from "@syncagent/js";
@Component({
imports: [SyncAgentCustomerChatComponent],
template: `<syncagent-customer-chat [config]="chatConfig" />`,
})
export class SupportPageComponent {
chatConfig: SyncAgentConfig = {
apiKey: "sa_your_api_key",
connectionString: "postgresql://user:pass@host:5432/db",
customerMode: true,
externalUserId: "user_123",
};
}
```
**Floating mode (default):**
```html
<syncagent-customer-chat
[apiKey]="apiKey"
[connectionString]="connectionString"
mode="floating"
position="bottom-right"
[defaultOpen]="false"
/>
```
**Inline mode:**
```html
<div style="height: 600px;">
<syncagent-customer-chat
[apiKey]="apiKey"
[connectionString]="connectionString"
mode="inline"
/>
</div>
```
**Dark mode with custom accent color:**
```html
<syncagent-customer-chat
[apiKey]="apiKey"
[connectionString]="connectionString"
[darkMode]="true"
accentColor="#8b5cf6"
title="Night Owl Support"
subtitle="We're here around the clock"
/>
```
**Handling output events:**
```typescript
@Component({
imports: [SyncAgentCustomerChatComponent],
template: `
<syncagent-customer-chat
[apiKey]="apiKey"
[connectionString]="connectionString"
(escalated)="onEscalated()"
(resolved)="onResolved($event)"
(guestIdentified)="onGuestIdentified($event)"
/>
`,
})
export class SupportComponent {
apiKey = "sa_your_api_key";
connectionString = "postgresql://user:pass@host:5432/db";
onEscalated() {
console.log("Conversation escalated to human agent");
}
onResolved(conversationId: string) {
console.log("Conversation resolved:", conversationId);
}
onGuestIdentified(identity: GuestIdentity) {
console.log("Guest identified:", identity.name, identity.guestId);
}
}
```
### CustomerChatService
The `CustomerChatService` is the underlying service used by the component. You can also use it directly to build fully custom chat UI while keeping the state management and API integration.
Provide it at the component level so each instance gets its own state:
```typescript
import { CustomerChatService } from "@syncagent/angular";
@Component({
providers: [CustomerChatService],
template: `<!-- your custom UI -->`,
})
export class CustomChatComponent implements OnInit {
constructor(public chat: CustomerChatService) {}
ngOnInit() {
this.chat.configure({
apiKey: "sa_your_api_key",
connectionString: "postgresql://user:pass@host:5432/db",
externalUserId: "user_123",
onEscalated: () => console.log("Escalated"),
onResolved: (id) => console.log("Resolved:", id),
});
}
}
```
#### Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `configure` | `configure(config: CustomerChatServiceConfig): void` | Initialize the service. Must be called before other methods. |
| `sendMessage` | `sendMessage(content: string, metadata?: Record<string, any>): Promise<void>` | Send a message and receive an AI response. Ignored if empty or already loading. |
| `rateConversation` | `rateConversation(rating: number): Promise<void>` | Rate the conversation (1–5). Throws if no active conversation. |
| `identifyGuest` | `identifyGuest(data: { name: string; email: string; phone?: string }): void` | Submit guest identification. Validates, generates ID, persists to localStorage. |
| `reset` | `reset(): void` | Clear all state and start fresh. |
#### Signals
| Signal | Type | Description |
|--------|------|-------------|
| `messages()` | `Message[]` | Conversation message history |
| `conversationId()` | `string \| null` | Current conversation ID |
| `isLoading()` | `boolean` | True while waiting for a response |
| `isEscalated()` | `boolean` | True when escalated to a human agent |
| `isResolved()` | `boolean` | True when the conversation is resolved |
| `error()` | `Error \| null` | Last error, if any |
| `welcomeMessage()` | `string \| null` | Welcome message (first interaction only) |
| `isConfigured()` | `boolean` | True after `configure()` has been called |
| `isIdentified()` | `boolean` | True when the guest has been identified |
| `guestIdentity()` | `GuestIdentity \| null` | Current guest identity |
#### Observables
| Observable | Type | Description |
|------------|------|-------------|
| `messages$` | `Observable<Message[]>` | Conversation history stream |
| `isLoading$` | `Observable<boolean>` | Loading state stream |
| `error$` | `Observable<Error \| null>` | Error stream |
| `escalated$` | `Observable<void>` | Emits when escalated to a human agent |
| `resolved$` | `Observable<string>` | Emits the conversation ID when resolved |
| `isIdentified$` | `Observable<boolean>` | Identification state stream |
| `guestIdentified$` | `Observable<GuestIdentity>` | Emits the identity when guest identification completes |
### Theming
The component uses the shared theme engine (`computeTheme()` from `@syncagent/js`) to generate a full color palette from your accent color and dark mode flag.
**Accent color** — Set the `accentColor` input to any valid CSS color (hex, rgb, hsl). This controls buttons, user message bubbles, focus outlines, and the header background.
```html
<syncagent-customer-chat accentColor="#8b5cf6" />
```
**Dark mode** — Set `[darkMode]="true"` to switch to a dark color scheme. All generated colors maintain WCAG AA contrast ratios.
```html
<syncagent-customer-chat [darkMode]="true" />
```
**Host element classes** — Use the `className` input to add CSS classes to the root container for additional styling:
```html
<syncagent-customer-chat className="my-chat-widget" />
```
```css
.my-chat-widget {
/* Override positioning, sizing, or add transitions */
max-height: 80vh;
}
```
The component uses inline styles and scoped CSS class prefixes (`syncagent-cc-*`) to prevent style leakage in both directions. A CSS custom property `--syncagent-accent` is set on focus-visible styles for focus indicator theming.
### Accessibility
The component is built to meet WCAG 2.1 AA standards:
**ARIA roles and labels:**
- Container: `role="region"` with `aria-label="Customer support chat"`
- Chat message list: `role="log"` with `aria-live="polite"` and `aria-relevant="additions"`
- Message input form: `role="form"` with `aria-label="Message input"`
- Rating widget: `role="group"` with `aria-label="Rate your experience"`
- Rating stars: `role="radiogroup"` with individual `role="radio"` and `aria-checked`
- Escalation banner: `role="status"` with `aria-live="polite"`
- Toggle button: `aria-expanded` reflecting panel state
**Keyboard navigation:**
- `Tab` moves focus through interactive elements (toggle, close, input, send, rating stars)
- `Enter` / `Space` activates buttons and submits forms
- `Arrow Left` / `Arrow Right` navigates between rating stars (roving tabindex)
- `Enter` on the text input submits the message
**Focus management:**
- When the guest form transitions to the chat panel, focus moves to the message input within 100ms
- All interactive elements have visible focus indicators (2px outline)
**Contrast requirements:**
- All text-on-background pairs meet a minimum 4.5:1 contrast ratio
- Focus indicators meet a minimum 3:1 contrast ratio against adjacent colors
- The theme engine enforces these guarantees regardless of the accent color provided
## API Reference

@@ -727,0 +1015,0 @@