Skip to content

Commit 881fef4

Browse files
brain-frogMatthew Olker
andauthored
feat(task): CAI-7811 Campaign Countdown (#682)
Co-authored-by: Matthew Olker <molker@cisco.com>
1 parent d027c4c commit 881fef4

10 files changed

Lines changed: 525 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React, {useEffect, useRef, useState, useCallback} from 'react';
2+
import {Text} from '@momentum-design/components/dist/react';
3+
import {CampaignCountdownProps} from './campaign-countdown.types';
4+
import {formatCountdown, calculateRemainingSeconds} from './campaign-countdown.utils';
5+
import {withMetrics} from '@webex/cc-ui-logging';
6+
import {TIME_LEFT} from '../constants';
7+
8+
const CampaignCountdown: React.FC<CampaignCountdownProps> = ({
9+
timeoutInSeconds,
10+
timeoutTimestamp,
11+
onTimeout,
12+
logger,
13+
}) => {
14+
const calculateRemaining = useCallback((): number => {
15+
return calculateRemainingSeconds(timeoutTimestamp, timeoutInSeconds, logger);
16+
}, [timeoutTimestamp, timeoutInSeconds, logger]);
17+
18+
const [remainingSeconds, setRemainingSeconds] = useState<number>(calculateRemaining());
19+
const [hasTimedOut, setHasTimedOut] = useState<boolean>(false);
20+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>();
21+
22+
useEffect(() => {
23+
setRemainingSeconds(calculateRemaining());
24+
setHasTimedOut(false);
25+
}, [timeoutTimestamp, timeoutInSeconds, calculateRemaining]);
26+
27+
useEffect(() => {
28+
if (timerRef.current !== undefined) {
29+
clearTimeout(timerRef.current);
30+
timerRef.current = undefined;
31+
}
32+
33+
if (remainingSeconds > 0) {
34+
timerRef.current = setTimeout(() => {
35+
// Recalculate from wall clock when using timeoutTimestamp to handle
36+
// browser throttling (background tabs, blocked main thread)
37+
const newRemaining = timeoutTimestamp !== undefined ? calculateRemaining() : remainingSeconds - 1;
38+
setRemainingSeconds(newRemaining);
39+
timerRef.current = undefined;
40+
}, 1000);
41+
} else if (remainingSeconds === 0 && !hasTimedOut) {
42+
setHasTimedOut(true);
43+
onTimeout?.();
44+
}
45+
46+
return () => {
47+
if (timerRef.current !== undefined) {
48+
clearTimeout(timerRef.current);
49+
timerRef.current = undefined;
50+
}
51+
};
52+
}, [remainingSeconds, hasTimedOut, onTimeout, timeoutTimestamp, calculateRemaining]);
53+
54+
const formattedTime = formatCountdown(remainingSeconds, logger);
55+
56+
return (
57+
<Text type="body-midsize-regular" className="task-text" data-testid="campaign-countdown">
58+
{TIME_LEFT} {formattedTime}
59+
</Text>
60+
);
61+
};
62+
63+
const CampaignCountdownWithMetrics = withMetrics(CampaignCountdown, 'CampaignCountdown');
64+
export default CampaignCountdownWithMetrics;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {ILogger} from '@webex/cc-store';
2+
3+
export interface CampaignCountdownProps {
4+
/**
5+
* Timeout duration in seconds.
6+
* Use this OR timeoutTimestamp, not both.
7+
*/
8+
timeoutInSeconds?: number;
9+
10+
/**
11+
* Epoch timestamp (in milliseconds) when the countdown should expire.
12+
* This is where `campaignPreviewOfferTimeout` from callProcessingDetails should be passed.
13+
* Can be provided as a string or number - will be parsed automatically.
14+
* Takes precedence over timeoutInSeconds if both are provided.
15+
*/
16+
timeoutTimestamp?: string | number;
17+
18+
/**
19+
* Callback fired when the countdown reaches zero
20+
*/
21+
onTimeout?: () => void;
22+
23+
/**
24+
* Logger instance for logging purposes
25+
*/
26+
logger?: ILogger;
27+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {ILogger} from '@webex/cc-store';
2+
3+
/**
4+
* Parses the timeoutTimestamp value (string or number) to a number.
5+
* The backend sends campaignPreviewOfferTimeout as a string epoch timestamp in milliseconds.
6+
*/
7+
export const parseTimeoutTimestamp = (value: string | number | undefined, logger?: ILogger): number => {
8+
try {
9+
if (value === undefined) {
10+
return 0;
11+
}
12+
if (typeof value === 'number') {
13+
return value;
14+
}
15+
if (typeof value === 'string') {
16+
const parsed = parseInt(value, 10);
17+
if (!isNaN(parsed)) {
18+
return parsed;
19+
}
20+
}
21+
return 0;
22+
} catch (error) {
23+
logger?.error('CC-Widgets: CampaignCountdown: Error in parseTimeoutTimestamp', {
24+
module: 'cc-components#campaign-countdown.utils.ts',
25+
method: 'parseTimeoutTimestamp',
26+
error: error.message,
27+
});
28+
return 0;
29+
}
30+
};
31+
32+
/**
33+
* Calculates remaining seconds based on either a timestamp or a direct seconds value.
34+
* If timeoutTimestamp is provided, it calculates the difference from now.
35+
* Otherwise, it uses timeoutInSeconds directly.
36+
*/
37+
export const calculateRemainingSeconds = (
38+
timeoutTimestamp?: string | number,
39+
timeoutInSeconds?: number,
40+
logger?: ILogger
41+
): number => {
42+
try {
43+
// timeoutTimestamp takes precedence
44+
if (timeoutTimestamp !== undefined) {
45+
const parsedTimestamp = parseTimeoutTimestamp(timeoutTimestamp, logger);
46+
if (parsedTimestamp > 0) {
47+
const now = Date.now();
48+
const diffMs = parsedTimestamp - now;
49+
return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0;
50+
}
51+
}
52+
// Fall back to timeoutInSeconds
53+
if (typeof timeoutInSeconds === 'number') {
54+
return Math.max(0, timeoutInSeconds);
55+
}
56+
return 0;
57+
} catch (error) {
58+
logger?.error('CC-Widgets: CampaignCountdown: Error in calculateRemainingSeconds', {
59+
module: 'cc-components#campaign-countdown.utils.ts',
60+
method: 'calculateRemainingSeconds',
61+
error: error.message,
62+
});
63+
return 0;
64+
}
65+
};
66+
67+
/**
68+
* Formats seconds into MM:SS format for countdown display
69+
*/
70+
export const formatCountdown = (seconds: number, logger?: ILogger): string => {
71+
try {
72+
const safeSeconds = Math.max(0, seconds);
73+
const minutes = Math.floor(safeSeconds / 60);
74+
const remainingSeconds = safeSeconds % 60;
75+
76+
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
77+
} catch (error) {
78+
logger?.error('CC-Widgets: CampaignCountdown: Error in formatCountdown', {
79+
module: 'cc-components#campaign-countdown.utils.ts',
80+
method: 'formatCountdown',
81+
error: error.message,
82+
});
83+
return '00:00';
84+
}
85+
};

packages/contact-center/cc-components/src/components/task/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ export const QUEUE = 'Queue:';
3737
export const PHONE_NUMBER = 'Phone Number:';
3838
export const CUSTOMER_NAME = 'Customer Name';
3939
export const RONA = 'RONA:';
40+
export const TIME_LEFT = 'Time left:';

packages/contact-center/cc-components/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import IncomingTaskComponent from './components/task/IncomingTask/incoming-task'
66
import TaskListComponent from './components/task/TaskList/task-list';
77
import OutdialCallComponent from './components/task/OutdialCall/outdial-call';
88
import CampaignErrorDialogComponent from './components/task/CampaignErrorDialog/campaign-error-dialog';
9+
import CampaignCountdownComponent from './components/task/CampaignCountdown/campaign-countdown';
910
import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript';
1011

1112
export {
@@ -17,10 +18,12 @@ export {
1718
TaskListComponent,
1819
OutdialCallComponent,
1920
CampaignErrorDialogComponent,
21+
CampaignCountdownComponent,
2022
RealTimeTranscriptComponent,
2123
};
2224
export * from './components/StationLogin/constants';
2325
export * from './components/StationLogin/station-login.types';
2426
export * from './components/UserState/user-state.types';
2527
export * from './components/task/task.types';
2628
export * from './components/task/CampaignErrorDialog/campaign-error-dialog.types';
29+
export * from './components/task/CampaignCountdown/campaign-countdown.types';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`CampaignCountdown Snapshots Rendering should match snapshot with 0 seconds timeout 1`] = `
4+
<div>
5+
<mdc-text
6+
class="task-text"
7+
data-testid="campaign-countdown"
8+
>
9+
Time left:
10+
11+
00:00
12+
</mdc-text>
13+
</div>
14+
`;
15+
16+
exports[`CampaignCountdown Snapshots Rendering should match snapshot with 30 seconds timeout 1`] = `
17+
<div>
18+
<mdc-text
19+
class="task-text"
20+
data-testid="campaign-countdown"
21+
>
22+
Time left:
23+
24+
00:30
25+
</mdc-text>
26+
</div>
27+
`;
28+
29+
exports[`CampaignCountdown Snapshots Rendering should match snapshot with 125 seconds timeout 1`] = `
30+
<div>
31+
<mdc-text
32+
class="task-text"
33+
data-testid="campaign-countdown"
34+
>
35+
Time left:
36+
37+
02:05
38+
</mdc-text>
39+
</div>
40+
`;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import {render} from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
import CampaignCountdownComponent from '../../../../src/components/task/CampaignCountdown/campaign-countdown';
5+
6+
describe('CampaignCountdown Snapshots', () => {
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
});
10+
11+
describe('Rendering', () => {
12+
it('should match snapshot with 30 seconds timeout', () => {
13+
const {container} = render(<CampaignCountdownComponent timeoutInSeconds={30} />);
14+
expect(container).toMatchSnapshot();
15+
});
16+
17+
it('should match snapshot with 0 seconds timeout', () => {
18+
const {container} = render(<CampaignCountdownComponent timeoutInSeconds={0} />);
19+
expect(container).toMatchSnapshot();
20+
});
21+
22+
it('should match snapshot with 125 seconds timeout', () => {
23+
const {container} = render(<CampaignCountdownComponent timeoutInSeconds={125} />);
24+
expect(container).toMatchSnapshot();
25+
});
26+
});
27+
});

0 commit comments

Comments
 (0)