Skip to content

Commit 6b37387

Browse files
committed
feat(emails): Add a countdown for email resending on signup and signin
1 parent d17db8b commit 6b37387

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,
@@ -56,6 +57,7 @@ const SigninTokenCode = ({
5657
const [animateBanner, setAnimateBanner] = useState(false);
5758
const [codeErrorMessage, setCodeErrorMessage] = useState<string>('');
5859
const [resendCodeLoading, setResendCodeLoading] = useState<boolean>(false);
60+
const [resendCountdown, setResendCountdown] = useState<number>(0);
5961

6062
const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
6163
const redirectTo =
@@ -86,6 +88,17 @@ const SigninTokenCode = ({
8688
GleanMetrics.loginConfirmation.view();
8789
}, []);
8890

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

@@ -277,15 +293,29 @@ const SigninTokenCode = ({
277293
<FtlMsg id="signin-token-code-code-expired">
278294
<p>Code expired?</p>
279295
</FtlMsg>
280-
<FtlMsg id="signin-token-code-resend-code-link">
281-
<button
282-
className="link-blue"
283-
onClick={handleResendCode}
284-
disabled={resendCodeLoading}
296+
{resendCountdown > 0 ? (
297+
<FtlMsg
298+
id="signin-token-code-resend-code-countdown"
299+
vars={{ seconds: resendCountdown }}
285300
>
286-
Email new code.
287-
</button>
288-
</FtlMsg>
301+
<button
302+
className="link-blue cursor-not-allowed opacity-50"
303+
disabled
304+
>
305+
Email new code in {resendCountdown} seconds
306+
</button>
307+
</FtlMsg>
308+
) : (
309+
<FtlMsg id="signin-token-code-resend-code-link">
310+
<button
311+
className="link-blue"
312+
onClick={handleResendCode}
313+
disabled={resendCodeLoading}
314+
>
315+
Email new code.
316+
</button>
317+
</FtlMsg>
318+
)}
289319
</div>
290320
</AppLayout>
291321
);

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,
@@ -67,6 +69,8 @@ const ConfirmSignupCode = ({
6769
const [resendStatus, setResendStatus] = useState<ResendStatus>(
6870
ResendStatus.none
6971
);
72+
const [resendCodeLoading, setResendCodeLoading] = useState<boolean>(false);
73+
const [resendCountdown, setResendCountdown] = useState<number>(0);
7074

7175
const navigateWithQuery = useNavigateWithQuery();
7276
const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
@@ -87,6 +91,17 @@ const ConfirmSignupCode = ({
8791
}
8892
}, [reason]);
8993

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

@@ -117,8 +132,10 @@ const ConfirmSignupCode = ({
117132
}
118133

119134
async function handleResendCode() {
135+
setResendCodeLoading(true);
120136
try {
121137
await session.sendVerificationCode();
138+
122139
// if resending a code is successful, clear any banner already present on screen
123140
setLocalizedErrorBannerHeading('');
124141
setResendStatus(ResendStatus.sent);
@@ -127,6 +144,9 @@ const ConfirmSignupCode = ({
127144
setLocalizedErrorBannerHeading(
128145
getLocalizedErrorMessage(ftlMsgResolver, error)
129146
);
147+
} finally {
148+
setResendCodeLoading(false);
149+
setResendCountdown(RESEND_CODE_COUNTDOWN);
130150
}
131151
}
132152

@@ -391,20 +411,34 @@ const ConfirmSignupCode = ({
391411
/>
392412

393413
<div className="animate-delayed-fade-in opacity-0 text-grey-500 text-sm inline-flex gap-1">
394-
<>
395-
<FtlMsg id="confirm-signup-code-code-expired">
396-
<p>Code expired?</p>
414+
<FtlMsg id="confirm-signup-code-code-expired">
415+
<p>Code expired?</p>
416+
</FtlMsg>
417+
{resendCountdown > 0 ? (
418+
<FtlMsg
419+
id="confirm-signup-code-resend-code-countdown"
420+
vars={{ seconds: resendCountdown }}
421+
>
422+
<button
423+
id="resend"
424+
className="link-blue cursor-not-allowed opacity-50"
425+
disabled
426+
>
427+
Email new code in {resendCountdown} seconds
428+
</button>
397429
</FtlMsg>
430+
) : (
398431
<FtlMsg id="confirm-signup-code-resend-code-link">
399432
<button
400433
id="resend"
401434
className="link-blue"
402435
onClick={handleResendCode}
436+
disabled={resendCodeLoading}
403437
>
404438
Email new code.
405439
</button>
406440
</FtlMsg>
407-
</>
441+
)}
408442
</div>
409443
</AppLayout>
410444
);

0 commit comments

Comments
 (0)