Skip to content

Commit c10cdf2

Browse files
authored
Merge pull request #19801 from mozilla/fxa-12796
feat(emails): Add a countdown for email resending on signup and signin
2 parents fc390eb + 6b37387 commit c10cdf2

6 files changed

Lines changed: 145 additions & 13 deletions

File tree

packages/fxa-settings/src/pages/Signin/SigninTokenCode/en.ftl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ signin-token-code-confirm-button = Confirm
1414
signin-token-code-code-expired = Code expired?
1515
# Link to resend a new code to the user's email.
1616
signin-token-code-resend-code-link = Email new code.
17+
# Countdown message shown when user must wait before resending code
18+
# { $seconds } represents the number of seconds remaining
19+
signin-token-code-resend-code-countdown =
20+
{ $seconds ->
21+
[one] Email new code in { $seconds } second
22+
*[other] Email new code in { $seconds } seconds
23+
}
1724
# Error displayed in a tooltip when the form is submitted without a code
1825
signin-token-code-required-error = Confirmation code required
1926
signin-token-code-resend-error = Something went wrong. A new code could not be sent.

packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ jest.mock('../../../lib/metrics', () => ({
3333
usePageViewEvent: jest.fn(),
3434
logViewEvent: jest.fn(),
3535
}));
36-
3736
jest.mock('../../../lib/glean', () => ({
3837
__esModule: true,
3938
default: {
@@ -172,6 +171,33 @@ describe('SigninTokenCode page', () => {
172171
await renderAndResend();
173172
screen.getByText('Something went wrong. A new code could not be sent.');
174173
});
174+
175+
it('shows countdown timer after successful resend', async () => {
176+
jest.useFakeTimers();
177+
session = mockSession();
178+
render();
179+
await waitFor(() => {
180+
screen.getByText('Code expired?');
181+
});
182+
183+
const resendButton = screen.getByRole('button', { name: 'Email new code.' });
184+
fireEvent.click(resendButton);
185+
186+
await waitFor(() => {
187+
expect(session.sendVerificationCode).toHaveBeenCalled();
188+
});
189+
190+
// Countdown button should appear and be disabled
191+
const resendButtonAfter = await waitFor(() => {
192+
const button = screen.getByRole('button', { name: /Email new code in/ });
193+
expect(button).toBeDisabled();
194+
return button;
195+
});
196+
197+
expect(resendButtonAfter.textContent).toMatch(/\d+/);
198+
199+
jest.useRealTimers();
200+
});
175201
});
176202

177203
describe('onSubmit code submission', () => {

packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import Banner, { ResendCodeSuccessBanner } from '../../../components/Banner';
2929
export const viewName = 'signin-token-code';
3030

3131
const SIX_DIGIT_NUMBER_REGEX = /^\d{6}$/;
32+
const RESEND_CODE_COUNTDOWN = 30;
3233

3334
const SigninTokenCode = ({
3435
finishOAuthFlowHandler,
@@ -57,6 +58,7 @@ const SigninTokenCode = ({
5758
const [animateBanner, setAnimateBanner] = useState(false);
5859
const [codeErrorMessage, setCodeErrorMessage] = useState<string>('');
5960
const [resendCodeLoading, setResendCodeLoading] = useState<boolean>(false);
61+
const [resendCountdown, setResendCountdown] = useState<number>(0);
6062

6163
const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
6264
const redirectTo =
@@ -87,6 +89,17 @@ const SigninTokenCode = ({
8789
GleanMetrics.loginConfirmation.view();
8890
}, []);
8991

92+
// Countdown timer for resend code
93+
useEffect(() => {
94+
if (resendCountdown > 0) {
95+
const timer = setTimeout(() => {
96+
setResendCountdown(resendCountdown - 1);
97+
}, 1000);
98+
return () => clearTimeout(timer);
99+
}
100+
return undefined;
101+
}, [resendCountdown]);
102+
90103
const handleAnimationEnd = () => {
91104
// We add the "shake" animation to bring attention to the success banner
92105
// when the success banner was already displayed. We have to remove the
@@ -98,6 +111,7 @@ const SigninTokenCode = ({
98111
setResendCodeLoading(true);
99112
try {
100113
await session.sendVerificationCode();
114+
101115
if (showResendSuccessBanner) {
102116
// shake the banner if it is already displayed
103117
setAnimateBanner(true);
@@ -117,6 +131,8 @@ const SigninTokenCode = ({
117131
);
118132
} finally {
119133
setResendCodeLoading(false);
134+
// Start countdown
135+
setResendCountdown(RESEND_CODE_COUNTDOWN);
120136
}
121137
};
122138

@@ -279,15 +295,29 @@ const SigninTokenCode = ({
279295
<FtlMsg id="signin-token-code-code-expired">
280296
<p>Code expired?</p>
281297
</FtlMsg>
282-
<FtlMsg id="signin-token-code-resend-code-link">
283-
<button
284-
className="link-blue"
285-
onClick={handleResendCode}
286-
disabled={resendCodeLoading}
298+
{resendCountdown > 0 ? (
299+
<FtlMsg
300+
id="signin-token-code-resend-code-countdown"
301+
vars={{ seconds: resendCountdown }}
287302
>
288-
Email new code.
289-
</button>
290-
</FtlMsg>
303+
<button
304+
className="link-blue cursor-not-allowed opacity-50"
305+
disabled
306+
>
307+
Email new code in {resendCountdown} seconds
308+
</button>
309+
</FtlMsg>
310+
) : (
311+
<FtlMsg id="signin-token-code-resend-code-link">
312+
<button
313+
className="link-blue"
314+
onClick={handleResendCode}
315+
disabled={resendCodeLoading}
316+
>
317+
Email new code.
318+
</button>
319+
</FtlMsg>
320+
)}
291321
</div>
292322
</AppLayout>
293323
);

packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/en.ftl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ confirm-signup-code-sync-button = Start syncing
1818
confirm-signup-code-code-expired = Code expired?
1919
# Link to resend a new code to the user's email.
2020
confirm-signup-code-resend-code-link = Email new code.
21+
# Countdown message shown when user must wait before resending code
22+
# { $seconds } represents the number of seconds remaining
23+
confirm-signup-code-resend-code-countdown =
24+
{ $seconds ->
25+
[one] Email new code in { $seconds } second
26+
*[other] Email new code in { $seconds } seconds
27+
}
2128
confirm-signup-code-success-alert = Account confirmed successfully
2229
# Error displayed in tooltip.
2330
confirm-signup-code-is-required-error = Confirmation code is required

packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,4 +580,32 @@ describe('Resending a new code from ConfirmSignupCode page', () => {
580580
expect(screen.getByText('Unexpected error')).toBeInTheDocument();
581581
});
582582
});
583+
584+
it('shows countdown timer after successful resend', async () => {
585+
jest.useFakeTimers();
586+
session = mockSession(true, false);
587+
588+
renderWithSession({
589+
session,
590+
integration: mockWebIntegration,
591+
});
592+
593+
const resendButton = screen.getByRole('button', { name: 'Email new code.' });
594+
fireEvent.click(resendButton);
595+
596+
await waitFor(() => {
597+
expect(session.sendVerificationCode).toHaveBeenCalled();
598+
});
599+
600+
// Countdown button should appear and be disabled
601+
const resendButtonAfter = await waitFor(() => {
602+
const button = screen.getByRole('button', { name: /Email new code in/ });
603+
expect(button).toBeDisabled();
604+
return button;
605+
});
606+
607+
expect(resendButtonAfter.textContent).toMatch(/\d+/);
608+
609+
jest.useRealTimers();
610+
});
583611
});

packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import { getSyncNavigate } from '../../Signin/utils';
4242

4343
export const viewName = 'confirm-signup-code';
4444

45+
const RESEND_CODE_COUNTDOWN = 30;
46+
4547
const ConfirmSignupCode = ({
4648
email,
4749
sessionToken,
@@ -68,6 +70,8 @@ const ConfirmSignupCode = ({
6870
const [resendStatus, setResendStatus] = useState<ResendStatus>(
6971
ResendStatus.none
7072
);
73+
const [resendCodeLoading, setResendCodeLoading] = useState<boolean>(false);
74+
const [resendCountdown, setResendCountdown] = useState<number>(0);
7175

7276
const navigateWithQuery = useNavigateWithQuery();
7377
const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
@@ -88,6 +92,17 @@ const ConfirmSignupCode = ({
8892
}
8993
}, [reason]);
9094

95+
// Countdown timer for resend code
96+
useEffect(() => {
97+
if (resendCountdown > 0) {
98+
const timer = setTimeout(() => {
99+
setResendCountdown(resendCountdown - 1);
100+
}, 1000);
101+
return () => clearTimeout(timer);
102+
}
103+
return undefined;
104+
}, [resendCountdown]);
105+
91106
const [localizedErrorBannerHeading, setLocalizedErrorBannerHeading] =
92107
useState('');
93108

@@ -118,8 +133,10 @@ const ConfirmSignupCode = ({
118133
}
119134

120135
async function handleResendCode() {
136+
setResendCodeLoading(true);
121137
try {
122138
await session.sendVerificationCode();
139+
123140
// if resending a code is successful, clear any banner already present on screen
124141
setLocalizedErrorBannerHeading('');
125142
setResendStatus(ResendStatus.sent);
@@ -128,6 +145,9 @@ const ConfirmSignupCode = ({
128145
setLocalizedErrorBannerHeading(
129146
getLocalizedErrorMessage(ftlMsgResolver, error)
130147
);
148+
} finally {
149+
setResendCodeLoading(false);
150+
setResendCountdown(RESEND_CODE_COUNTDOWN);
131151
}
132152
}
133153

@@ -393,20 +413,34 @@ const ConfirmSignupCode = ({
393413
/>
394414

395415
<div className="animate-delayed-fade-in opacity-0 text-grey-500 text-sm inline-flex gap-1">
396-
<>
397-
<FtlMsg id="confirm-signup-code-code-expired">
398-
<p>Code expired?</p>
416+
<FtlMsg id="confirm-signup-code-code-expired">
417+
<p>Code expired?</p>
418+
</FtlMsg>
419+
{resendCountdown > 0 ? (
420+
<FtlMsg
421+
id="confirm-signup-code-resend-code-countdown"
422+
vars={{ seconds: resendCountdown }}
423+
>
424+
<button
425+
id="resend"
426+
className="link-blue cursor-not-allowed opacity-50"
427+
disabled
428+
>
429+
Email new code in {resendCountdown} seconds
430+
</button>
399431
</FtlMsg>
432+
) : (
400433
<FtlMsg id="confirm-signup-code-resend-code-link">
401434
<button
402435
id="resend"
403436
className="link-blue"
404437
onClick={handleResendCode}
438+
disabled={resendCodeLoading}
405439
>
406440
Email new code.
407441
</button>
408442
</FtlMsg>
409-
</>
443+
)}
410444
</div>
411445
</AppLayout>
412446
);

0 commit comments

Comments
 (0)