@syncagent/angular
Advanced tools
+235
-3
| 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 }; |
+235
-3
| 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 }; |
+1108
-8
| "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'">🔄 </span> | ||
| <span *ngIf="msg.systemType === 'resolution'">✓ </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, |
+1101
-0
@@ -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'">🔄 </span> | ||
| <span *ngIf="msg.systemType === 'resolution'">✓ </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, |
+10
-2
| { | ||
| "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 @@ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
198640
146.04%3585
197.26%1084
36.18%6
50%7
16.67%5
Infinity%