-
Notifications
You must be signed in to change notification settings - Fork 65
feat(task): CAI-7811 Campaign Countdown #682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import React, {useEffect, useRef, useState, useCallback} from 'react'; | ||
| import {Text} from '@momentum-design/components/dist/react'; | ||
| import {CampaignCountdownProps} from './campaign-countdown.types'; | ||
| import {formatCountdown, calculateRemainingSeconds} from './campaign-countdown.utils'; | ||
| import {withMetrics} from '@webex/cc-ui-logging'; | ||
| import {TIME_LEFT} from '../constants'; | ||
|
|
||
| const CampaignCountdown: React.FC<CampaignCountdownProps> = ({ | ||
| timeoutInSeconds, | ||
| timeoutTimestamp, | ||
| onTimeout, | ||
| logger, | ||
| }) => { | ||
| const calculateRemaining = useCallback((): number => { | ||
| return calculateRemainingSeconds(timeoutTimestamp, timeoutInSeconds, logger); | ||
| }, [timeoutTimestamp, timeoutInSeconds, logger]); | ||
|
|
||
| const [remainingSeconds, setRemainingSeconds] = useState<number>(calculateRemaining()); | ||
| const [hasTimedOut, setHasTimedOut] = useState<boolean>(false); | ||
| const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(); | ||
|
|
||
| useEffect(() => { | ||
| setRemainingSeconds(calculateRemaining()); | ||
| setHasTimedOut(false); | ||
| }, [timeoutTimestamp, timeoutInSeconds, calculateRemaining]); | ||
|
|
||
| useEffect(() => { | ||
| if (timerRef.current !== undefined) { | ||
| clearTimeout(timerRef.current); | ||
| timerRef.current = undefined; | ||
| } | ||
|
|
||
| if (remainingSeconds > 0) { | ||
| timerRef.current = setTimeout(() => { | ||
| // Recalculate from wall clock when using timeoutTimestamp to handle | ||
| // browser throttling (background tabs, blocked main thread) | ||
| const newRemaining = timeoutTimestamp !== undefined ? calculateRemaining() : remainingSeconds - 1; | ||
| setRemainingSeconds(newRemaining); | ||
| timerRef.current = undefined; | ||
|
brain-frog marked this conversation as resolved.
|
||
| }, 1000); | ||
| } else if (remainingSeconds === 0 && !hasTimedOut) { | ||
| setHasTimedOut(true); | ||
|
brain-frog marked this conversation as resolved.
|
||
| onTimeout?.(); | ||
|
Comment on lines
+41
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch calls Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| return () => { | ||
| if (timerRef.current !== undefined) { | ||
| clearTimeout(timerRef.current); | ||
| timerRef.current = undefined; | ||
| } | ||
| }; | ||
| }, [remainingSeconds, hasTimedOut, onTimeout, timeoutTimestamp, calculateRemaining]); | ||
|
|
||
| const formattedTime = formatCountdown(remainingSeconds, logger); | ||
|
|
||
| return ( | ||
| <Text type="body-midsize-regular" className="task-text" data-testid="campaign-countdown"> | ||
| {TIME_LEFT} {formattedTime} | ||
| </Text> | ||
| ); | ||
| }; | ||
|
|
||
| const CampaignCountdownWithMetrics = withMetrics(CampaignCountdown, 'CampaignCountdown'); | ||
| export default CampaignCountdownWithMetrics; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import {ILogger} from '@webex/cc-store'; | ||
|
|
||
| export interface CampaignCountdownProps { | ||
| /** | ||
| * Timeout duration in seconds. | ||
| * Use this OR timeoutTimestamp, not both. | ||
| */ | ||
| timeoutInSeconds?: number; | ||
|
cmullenx marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Epoch timestamp (in milliseconds) when the countdown should expire. | ||
| * This is where `campaignPreviewOfferTimeout` from callProcessingDetails should be passed. | ||
| * Can be provided as a string or number - will be parsed automatically. | ||
| * Takes precedence over timeoutInSeconds if both are provided. | ||
| */ | ||
| timeoutTimestamp?: string | number; | ||
|
|
||
| /** | ||
| * Callback fired when the countdown reaches zero | ||
| */ | ||
| onTimeout?: () => void; | ||
|
|
||
| /** | ||
| * Logger instance for logging purposes | ||
| */ | ||
| logger?: ILogger; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import {ILogger} from '@webex/cc-store'; | ||
|
|
||
| /** | ||
| * Parses the timeoutTimestamp value (string or number) to a number. | ||
| * The backend sends campaignPreviewOfferTimeout as a string epoch timestamp in milliseconds. | ||
| */ | ||
| export const parseTimeoutTimestamp = (value: string | number | undefined, logger?: ILogger): number => { | ||
| try { | ||
| if (value === undefined) { | ||
| return 0; | ||
| } | ||
| if (typeof value === 'number') { | ||
| return value; | ||
| } | ||
| if (typeof value === 'string') { | ||
| const parsed = parseInt(value, 10); | ||
| if (!isNaN(parsed)) { | ||
| return parsed; | ||
| } | ||
| } | ||
| return 0; | ||
| } catch (error) { | ||
| logger?.error('CC-Widgets: CampaignCountdown: Error in parseTimeoutTimestamp', { | ||
| module: 'cc-components#campaign-countdown.utils.ts', | ||
| method: 'parseTimeoutTimestamp', | ||
| error: error.message, | ||
| }); | ||
| return 0; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Calculates remaining seconds based on either a timestamp or a direct seconds value. | ||
| * If timeoutTimestamp is provided, it calculates the difference from now. | ||
| * Otherwise, it uses timeoutInSeconds directly. | ||
| */ | ||
| export const calculateRemainingSeconds = ( | ||
| timeoutTimestamp?: string | number, | ||
| timeoutInSeconds?: number, | ||
| logger?: ILogger | ||
| ): number => { | ||
| try { | ||
| // timeoutTimestamp takes precedence | ||
| if (timeoutTimestamp !== undefined) { | ||
| const parsedTimestamp = parseTimeoutTimestamp(timeoutTimestamp, logger); | ||
| if (parsedTimestamp > 0) { | ||
| const now = Date.now(); | ||
| const diffMs = parsedTimestamp - now; | ||
| return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0; | ||
| } | ||
| } | ||
| // Fall back to timeoutInSeconds | ||
| if (typeof timeoutInSeconds === 'number') { | ||
| return Math.max(0, timeoutInSeconds); | ||
| } | ||
| return 0; | ||
| } catch (error) { | ||
| logger?.error('CC-Widgets: CampaignCountdown: Error in calculateRemainingSeconds', { | ||
| module: 'cc-components#campaign-countdown.utils.ts', | ||
| method: 'calculateRemainingSeconds', | ||
| error: error.message, | ||
| }); | ||
| return 0; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Formats seconds into MM:SS format for countdown display | ||
| */ | ||
| export const formatCountdown = (seconds: number, logger?: ILogger): string => { | ||
| try { | ||
| const safeSeconds = Math.max(0, seconds); | ||
| const minutes = Math.floor(safeSeconds / 60); | ||
| const remainingSeconds = safeSeconds % 60; | ||
|
|
||
| return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; | ||
| } catch (error) { | ||
| logger?.error('CC-Widgets: CampaignCountdown: Error in formatCountdown', { | ||
| module: 'cc-components#campaign-countdown.utils.ts', | ||
| method: 'formatCountdown', | ||
| error: error.message, | ||
| }); | ||
| return '00:00'; | ||
| } | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,3 +37,4 @@ export const QUEUE = 'Queue:'; | |
| export const PHONE_NUMBER = 'Phone Number:'; | ||
| export const CUSTOMER_NAME = 'Customer Name'; | ||
| export const RONA = 'RONA:'; | ||
| export const TIME_LEFT = 'Time left:'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you confirm that time left isn't used already in incomingtask component? and if so, share the const
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. its hard coded there, const can be shared to incoming task in a follow up enhancement dedicated to replacing hard coded text with consts |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| // Jest Snapshot v1, https://goo.gl/fbAQLP | ||
|
|
||
| exports[`CampaignCountdown Snapshots Rendering should match snapshot with 0 seconds timeout 1`] = ` | ||
| <div> | ||
| <mdc-text | ||
| class="task-text" | ||
| data-testid="campaign-countdown" | ||
| > | ||
| Time left: | ||
|
|
||
| 00:00 | ||
| </mdc-text> | ||
| </div> | ||
| `; | ||
|
|
||
| exports[`CampaignCountdown Snapshots Rendering should match snapshot with 30 seconds timeout 1`] = ` | ||
| <div> | ||
| <mdc-text | ||
| class="task-text" | ||
| data-testid="campaign-countdown" | ||
| > | ||
| Time left: | ||
|
|
||
| 00:30 | ||
| </mdc-text> | ||
| </div> | ||
| `; | ||
|
|
||
| exports[`CampaignCountdown Snapshots Rendering should match snapshot with 125 seconds timeout 1`] = ` | ||
| <div> | ||
| <mdc-text | ||
| class="task-text" | ||
| data-testid="campaign-countdown" | ||
| > | ||
| Time left: | ||
|
|
||
| 02:05 | ||
| </mdc-text> | ||
| </div> | ||
| `; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import React from 'react'; | ||
| import {render} from '@testing-library/react'; | ||
| import '@testing-library/jest-dom'; | ||
| import CampaignCountdownComponent from '../../../../src/components/task/CampaignCountdown/campaign-countdown'; | ||
|
|
||
| describe('CampaignCountdown Snapshots', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('Rendering', () => { | ||
| it('should match snapshot with 30 seconds timeout', () => { | ||
| const {container} = render(<CampaignCountdownComponent timeoutInSeconds={30} />); | ||
| expect(container).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| it('should match snapshot with 0 seconds timeout', () => { | ||
| const {container} = render(<CampaignCountdownComponent timeoutInSeconds={0} />); | ||
| expect(container).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| it('should match snapshot with 125 seconds timeout', () => { | ||
| const {container} = render(<CampaignCountdownComponent timeoutInSeconds={125} />); | ||
| expect(container).toMatchSnapshot(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the same validity check here that
calculateRemainingSecondsuses, rather than onlytimeoutTimestamp !== undefined. With inputs liketimeoutTimestamp: ''(or any unparsable string) plustimeoutInSeconds,calculateRemaining()falls back to the originaltimeoutInSecondsvalue, and this branch reassigns that same value every tick, so the countdown never decreases andonTimeoutnever fires. This can happen when backend data sends an empty/invalid timestamp while the seconds fallback is provided.Useful? React with 👍 / 👎.