diff --git a/packages/contact-center/cc-components/src/components/task/TaskTranscript/styles.scss b/packages/contact-center/cc-components/src/components/task/TaskTranscript/styles.scss new file mode 100644 index 000000000..79b60bb12 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/TaskTranscript/styles.scss @@ -0,0 +1,103 @@ +.task-transcript { + background: var(--mds-color-theme-background-primary-normal); + border-radius: 0.5rem; + display: flex; + flex-direction: column; + min-height: 12rem; + padding: 0.75rem 0.875rem; +} + +.task-transcript__tabs { + align-items: center; + column-gap: 1.5rem; + display: flex; + margin-bottom: 0.75rem; +} + +.task-transcript__tab { + background: transparent; + border: 0; + border-bottom: 0.125rem solid transparent; + color: var(--mds-color-theme-text-secondary-normal); + cursor: pointer; + font-size: 0.9375rem; + font-weight: 500; + line-height: 1.25rem; + padding: 0.125rem 0; +} + +.task-transcript__tab--active { + border-bottom-color: var(--mds-color-theme-text-primary-normal); + color: var(--mds-color-theme-text-primary-normal); + font-weight: 700; +} + +.task-transcript__content { + display: flex; + flex: 1; + flex-direction: column; + overflow-y: auto; + row-gap: 0.875rem; +} + +.task-transcript__event { + color: var(--mds-color-theme-text-secondary-normal); + font-size: 0.75rem; + line-height: 1rem; + text-align: center; +} + +.task-transcript__item { + align-items: flex-start; + column-gap: 0.625rem; + display: flex; +} + +.task-transcript__avatar-wrap { + flex-shrink: 0; + height: 1.75rem; + width: 1.75rem; +} + +.task-transcript__avatar-image { + border-radius: 50%; + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +.task-transcript__avatar-fallback { + --mdc-avatar-size: 1.75rem; +} + +.task-transcript__text-block { + min-width: 0; +} + +.task-transcript__meta { + color: var(--mds-color-theme-text-secondary-normal); + display: flex; + font-size: 0.75rem; + line-height: 1rem; +} + +.task-transcript__time { + color: #2e6de5; + margin-left: 0.5rem; + text-decoration: underline; +} + +.task-transcript__message { + color: var(--mds-color-theme-text-primary-normal); + font-size: 1.0625rem; + line-height: 1.5rem; + margin: 0.125rem 0 0; +} + +.task-transcript__empty { + color: var(--mds-color-theme-text-secondary-normal); + font-size: 0.875rem; + line-height: 1.25rem; + padding: 1rem 0.125rem; +} diff --git a/packages/contact-center/cc-components/src/components/task/TaskTranscript/task-transcript.tsx b/packages/contact-center/cc-components/src/components/task/TaskTranscript/task-transcript.tsx new file mode 100644 index 000000000..cdfb391dd --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/TaskTranscript/task-transcript.tsx @@ -0,0 +1,99 @@ +import React, {useMemo} from 'react'; +import {Avatar} from '@momentum-design/components/dist/react'; +import {withMetrics} from '@webex/cc-ui-logging'; +import {TaskTranscriptComponentProps} from '../task.types'; +import './styles.scss'; + +const formatSpeaker = (speaker?: string) => speaker || 'Unknown'; + +const TaskTranscriptComponent: React.FC = ({ + ivrTranscript = '', + liveTranscriptEntries = [], + activeTab = 'live', + onTabChange, + className, +}) => { + const sortedEntries = useMemo( + () => + [...liveTranscriptEntries].sort((a, b) => { + if (a.timestamp === b.timestamp) return 0; + return a.timestamp > b.timestamp ? 1 : -1; + }), + [liveTranscriptEntries] + ); + + return ( +
+
+ + +
+ + {activeTab === 'ivr' ? ( +
+ {ivrTranscript || 'No IVR transcript available.'} +
+ ) : ( +
+ {sortedEntries.length === 0 ? ( +
No live transcript available.
+ ) : ( + <> + {sortedEntries[0].event ? ( +
+ {sortedEntries[0].event} +
+ ) : null} + {sortedEntries.map((entry) => ( +
+
+ {entry.avatarUrl ? ( + {formatSpeaker(entry.speaker)} + ) : ( + + {entry.initials || (entry.isCustomer ? 'CU' : 'YO')} + + )} +
+
+
+ {formatSpeaker(entry.speaker)} + {entry.displayTime ? {entry.displayTime} : null} +
+

{entry.message}

+
+
+ ))} + + )} +
+ )} +
+ ); +}; + +const TaskTranscriptComponentWithMetrics = withMetrics(TaskTranscriptComponent, 'TaskTranscript'); + +export default TaskTranscriptComponentWithMetrics; diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index aac63d7ca..8a0514910 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -153,6 +153,28 @@ export type TaskListComponentProps = Pick< > & Partial>; +export type TranscriptTab = 'ivr' | 'live'; + +export interface TaskTranscriptEntry { + id: string; + speaker: string; + message: string; + timestamp: number; + displayTime?: string; + event?: string; + isCustomer?: boolean; + avatarUrl?: string; + initials?: string; +} + +export interface TaskTranscriptComponentProps { + ivrTranscript?: string; + liveTranscriptEntries?: TaskTranscriptEntry[]; + activeTab?: TranscriptTab; + onTabChange?: (tab: TranscriptTab) => void; + className?: string; +} + /** * Interface representing the properties for control actions on a task. */ diff --git a/packages/contact-center/cc-components/src/index.ts b/packages/contact-center/cc-components/src/index.ts index d4d692fdb..54150cec6 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -5,6 +5,7 @@ import CallControlCADComponent from './components/task/CallControlCAD/call-contr import IncomingTaskComponent from './components/task/IncomingTask/incoming-task'; import TaskListComponent from './components/task/TaskList/task-list'; import OutdialCallComponent from './components/task/OutdialCall/outdial-call'; +import TaskTranscriptComponent from './components/task/TaskTranscript/task-transcript'; export { UserStateComponent, @@ -14,6 +15,7 @@ export { IncomingTaskComponent, TaskListComponent, OutdialCallComponent, + TaskTranscriptComponent, }; export * from './components/StationLogin/constants'; export * from './components/StationLogin/station-login.types'; diff --git a/packages/contact-center/cc-components/src/wc.ts b/packages/contact-center/cc-components/src/wc.ts index 1553aab77..3e52d2b11 100644 --- a/packages/contact-center/cc-components/src/wc.ts +++ b/packages/contact-center/cc-components/src/wc.ts @@ -6,6 +6,7 @@ import CallControlCADComponent from './components/task/CallControl/call-control' import IncomingTaskComponent from './components/task/IncomingTask/incoming-task'; import TaskListComponent from './components/task/TaskList/task-list'; import OutdialCallComponent from './components/task/OutdialCall/outdial-call'; +import TaskTranscriptComponent from './components/task/TaskTranscript/task-transcript'; const WebUserState = r2wc(UserStateComponent, { props: { @@ -106,3 +107,16 @@ const WebOutdialCallComponent = r2wc(OutdialCallComponent); if (!customElements.get('component-cc-out-dial-call')) { customElements.define('component-cc-out-dial-call', WebOutdialCallComponent); } + +const WebTaskTranscriptComponent = r2wc(TaskTranscriptComponent, { + props: { + ivrTranscript: 'string', + liveTranscriptEntries: 'json', + activeTab: 'string', + onTabChange: 'function', + className: 'string', + }, +}); +if (!customElements.get('component-cc-task-transcript')) { + customElements.define('component-cc-task-transcript', WebTaskTranscriptComponent); +} diff --git a/packages/contact-center/cc-components/tests/components/task/TaskTranscript/task-transcript.tsx b/packages/contact-center/cc-components/tests/components/task/TaskTranscript/task-transcript.tsx new file mode 100644 index 000000000..a96334387 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/TaskTranscript/task-transcript.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {fireEvent, render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TaskTranscriptComponent from '../../../../src/components/task/TaskTranscript/task-transcript'; +import {TaskTranscriptComponentProps} from '../../../../src/components/task/task.types'; + +describe('TaskTranscriptComponent', () => { + const defaultProps: TaskTranscriptComponentProps = { + ivrTranscript: 'IVR summary text', + activeTab: 'live', + liveTranscriptEntries: [ + { + id: '2', + speaker: '%Customer%', + message: 'Customer message', + timestamp: 2, + displayTime: '00:02', + isCustomer: true, + }, + { + id: '1', + speaker: '%You%', + message: 'Agent message', + timestamp: 1, + displayTime: '00:01', + }, + ], + }; + + it('renders live transcript entries and sorts by timestamp', () => { + render(); + + const messages = screen.getAllByTestId('task-transcript:item'); + expect(messages).toHaveLength(2); + expect(messages[0]).toHaveTextContent('Agent message'); + expect(messages[1]).toHaveTextContent('Customer message'); + }); + + it('renders ivr content when ivr tab active', () => { + render(); + expect(screen.getByTestId('task-transcript:ivr-content')).toHaveTextContent('IVR summary text'); + }); + + it('calls onTabChange when tabs clicked', () => { + const onTabChange = jest.fn(); + render(); + + fireEvent.click(screen.getByTestId('task-transcript:ivr-tab')); + expect(onTabChange).toHaveBeenCalledWith('ivr'); + }); +}); diff --git a/packages/contact-center/cc-widgets/src/index.ts b/packages/contact-center/cc-widgets/src/index.ts index ea1562e11..10ee48c49 100644 --- a/packages/contact-center/cc-widgets/src/index.ts +++ b/packages/contact-center/cc-widgets/src/index.ts @@ -1,6 +1,6 @@ import {StationLogin} from '@webex/cc-station-login'; import {UserState} from '@webex/cc-user-state'; -import {IncomingTask, TaskList, CallControl, CallControlCAD, OutdialCall} from '@webex/cc-task'; +import {IncomingTask, TaskList, CallControl, CallControlCAD, OutdialCall, TaskTranscript} from '@webex/cc-task'; import {DigitalChannels} from '@webex/cc-digital-channels'; import store from '@webex/cc-store'; import '@momentum-ui/core/css/momentum-ui.min.css'; @@ -13,6 +13,7 @@ export { CallControlCAD, TaskList, OutdialCall, + TaskTranscript, DigitalChannels, store, }; diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index 5fd608367..a7652dc93 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -2,7 +2,7 @@ import r2wc from '@r2wc/react-to-web-component'; import {StationLogin} from '@webex/cc-station-login'; import {UserState} from '@webex/cc-user-state'; import store from '@webex/cc-store'; -import {TaskList, IncomingTask, CallControl, CallControlCAD, OutdialCall} from '@webex/cc-task'; +import {TaskList, IncomingTask, CallControl, CallControlCAD, OutdialCall, TaskTranscript} from '@webex/cc-task'; import {DigitalChannels} from '@webex/cc-digital-channels'; const WebUserState = r2wc(UserState, { @@ -53,6 +53,15 @@ const WebCallControlCAD = r2wc(CallControlCAD, { }); const WebOutdialCall = r2wc(OutdialCall, {}); +const WebTaskTranscript = r2wc(TaskTranscript, { + props: { + ivrTranscript: 'string', + liveTranscriptEntries: 'json', + activeTab: 'string', + onTabChange: 'function', + className: 'string', + }, +}); const WebDigitalChannels = r2wc(DigitalChannels, {}); @@ -66,6 +75,7 @@ const components = [ {name: 'widget-cc-call-control', component: WebCallControl}, {name: 'widget-cc-outdial-call', component: WebOutdialCall}, {name: 'widget-cc-call-control-cad', component: WebCallControlCAD}, + {name: 'widget-cc-task-transcript', component: WebTaskTranscript}, {name: 'widget-cc-digital-channels', component: WebDigitalChannels}, ]; diff --git a/packages/contact-center/task/src/TaskTranscript/index.tsx b/packages/contact-center/task/src/TaskTranscript/index.tsx new file mode 100644 index 000000000..bffce834e --- /dev/null +++ b/packages/contact-center/task/src/TaskTranscript/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; + +import store from '@webex/cc-store'; +import {TaskTranscriptComponent} from '@webex/cc-components'; +import {useTaskTranscript} from '../helper'; +import {TaskTranscriptProps} from '../task.types'; + +const TaskTranscriptInternal: React.FunctionComponent = observer((props) => { + const result = useTaskTranscript(props); + return ; +}); + +const TaskTranscript: React.FunctionComponent = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('TaskTranscript', error); + }} + > + + + ); +}; + +export {TaskTranscript}; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index c3e2b6129..d8780e81d 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -4,6 +4,7 @@ import { useCallControlProps, UseTaskListProps, UseTaskProps, + UseTaskTranscriptProps, useOutdialCallProps, TargetType, TARGET_TYPE, @@ -146,6 +147,18 @@ export const useTaskList = (props: UseTaskListProps) => { return {taskList, acceptTask, declineTask, onTaskSelect, isBrowser}; }; +export const useTaskTranscript = (props: UseTaskTranscriptProps) => { + const {ivrTranscript = '', liveTranscriptEntries = [], activeTab = 'live', onTabChange, className} = props; + + return { + ivrTranscript, + liveTranscriptEntries, + activeTab, + onTabChange, + className, + }; +}; + export const useIncomingTask = (props: UseTaskProps) => { const {onAccepted, onRejected, deviceType, incomingTask, logger} = props; const isBrowser = deviceType === 'BROWSER'; diff --git a/packages/contact-center/task/src/index.ts b/packages/contact-center/task/src/index.ts index a8e8eedc6..ab9f361f7 100644 --- a/packages/contact-center/task/src/index.ts +++ b/packages/contact-center/task/src/index.ts @@ -3,4 +3,5 @@ import {TaskList} from './TaskList'; import {CallControl} from './CallControl'; import {OutdialCall} from './OutdialCall'; import {CallControlCAD} from './CallControlCAD'; -export {IncomingTask, TaskList, CallControl, OutdialCall, CallControlCAD}; +import {TaskTranscript} from './TaskTranscript'; +export {IncomingTask, TaskList, CallControl, OutdialCall, CallControlCAD, TaskTranscript}; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index c0c759382..036c28953 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -1,4 +1,11 @@ -import {TaskProps, ControlProps, OutdialCallProps} from '@webex/cc-components'; +import { + TaskProps, + ControlProps, + OutdialCallProps, + TaskTranscriptComponentProps, + TaskTranscriptEntry, + TranscriptTab, +} from '@webex/cc-components'; export type UseTaskProps = Pick & Partial>; @@ -10,6 +17,11 @@ export type IncomingTaskProps = Pick & Partial>; +export type TaskTranscriptProps = Pick & + Partial>; + +export type UseTaskTranscriptProps = TaskTranscriptProps; + export type CallControlProps = Partial< Pick< ControlProps, @@ -32,6 +44,8 @@ export type useCallControlProps = Pick< Partial>; export type useOutdialCallProps = Pick; + +export type {TaskTranscriptEntry, TranscriptTab}; export interface OutdialProps { /** * Flag to determine if the address book is enabled. diff --git a/packages/contact-center/task/tests/TaskTranscript/index.tsx b/packages/contact-center/task/tests/TaskTranscript/index.tsx new file mode 100644 index 000000000..28c5250ec --- /dev/null +++ b/packages/contact-center/task/tests/TaskTranscript/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import {TaskTranscript} from '../../src/TaskTranscript'; +import * as helper from '../../src/helper'; +import store from '@webex/cc-store'; + +jest.mock('@webex/cc-store', () => ({ + logger: { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +describe('TaskTranscript Widget', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('passes props to useTaskTranscript hook', () => { + const spy = jest.spyOn(helper, 'useTaskTranscript'); + + render( + + ); + + expect(spy).toHaveBeenCalledWith({ + ivrTranscript: 'ivr', + liveTranscriptEntries: [{id: '1', speaker: 'Agent', message: 'Hello', timestamp: 1}], + }); + expect(screen.getByTestId('task-transcript:root')).toBeInTheDocument(); + }); + + it('renders fallback when an error is thrown', () => { + const mockOnErrorCallback = jest.fn(); + store.onErrorCallback = mockOnErrorCallback; + jest.spyOn(helper, 'useTaskTranscript').mockImplementation(() => { + throw new Error('TaskTranscript test error'); + }); + + const {container} = render(); + expect(container.firstChild).toBeNull(); + expect(mockOnErrorCallback).toHaveBeenCalledWith('TaskTranscript', expect.any(Error)); + }); +});