diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 53058b55bb..15af6d8ab8 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -287,7 +287,12 @@ "cryptoRequiresSignIn": "Melde dich an, um mit Kryptowährung zu bezahlen.", "resendEmail": "E-Mail erneut senden", "emailResent": "Wir haben dir die E-Mail erneut gesendet", - "resendEmailError": "Wir konnten die E-Mail nicht senden. Bitte versuche es später erneut." + "resendEmailError": "Wir konnten die E-Mail nicht senden. Bitte versuche es später erneut.", + "checkEmail": { + "title": "Prüfe deine E-Mails", + "description": "Deine Zahlung war erfolgreich. Wir haben einen Link zum Abschließen der Kontoeinrichtung gesendet an:", + "spamHint": "Nicht gefunden? Sieh in deinem Spam-Ordner nach." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 19273f6e47..46ea8a29ce 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -308,7 +308,12 @@ "cryptoRequiresSignIn": "Log in to pay with crypto.", "resendEmail": "Resend email", "emailResent": "We've sent you the email again", - "resendEmailError": "We couldn't send the email. Please try again later." + "resendEmailError": "We couldn't send the email. Please try again later.", + "checkEmail": { + "title": "Check your email", + "description": "Your payment was successful. We've sent a link to finish setting up your account to:", + "spamHint": "Can't find it? Check your spam folder." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 79211c9184..5d986651ff 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -287,7 +287,12 @@ "cryptoRequiresSignIn": "Inicia sesión para pagar con criptomonedas.", "resendEmail": "Reenviar correo", "emailResent": "Te hemos vuelto a enviar el correo", - "resendEmailError": "No hemos podido enviar el correo. Inténtalo de nuevo más tarde." + "resendEmailError": "No hemos podido enviar el correo. Inténtalo de nuevo más tarde.", + "checkEmail": { + "title": "Revisa tu correo", + "description": "El pago se ha completado. Hemos enviado un enlace para terminar de configurar tu cuenta a:", + "spamHint": "¿No lo encuentras? Revisa la carpeta de spam." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 7c4d971e90..a90f36deda 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -287,7 +287,12 @@ "cryptoRequiresSignIn": "Connectez-vous pour payer en cryptomonnaie.", "resendEmail": "Renvoyer l'e-mail", "emailResent": "Nous vous avons renvoyé l'e-mail", - "resendEmailError": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer plus tard." + "resendEmailError": "Nous n'avons pas pu envoyer l'e-mail. Veuillez réessayer plus tard.", + "checkEmail": { + "title": "Consultez vos e-mails", + "description": "Votre paiement a été effectué. Nous avons envoyé un lien pour finaliser la configuration de votre compte à :", + "spamHint": "Vous ne le trouvez pas ? Vérifiez votre dossier de spam." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 7dbee2fb86..be17f2da81 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -308,7 +308,12 @@ "cryptoRequiresSignIn": "Accedi per pagare con criptovalute.", "resendEmail": "Invia di nuovo l'email", "emailResent": "Ti abbiamo inviato di nuovo l'email", - "resendEmailError": "Non siamo riusciti a inviare l'email. Riprova più tardi." + "resendEmailError": "Non siamo riusciti a inviare l'email. Riprova più tardi.", + "checkEmail": { + "title": "Controlla la tua email", + "description": "Il pagamento è andato a buon fine. Abbiamo inviato un link per completare la configurazione del tuo account a:", + "spamHint": "Non la trovi? Controlla la cartella dello spam." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index 224acb5bd9..ce35ea0818 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -308,7 +308,12 @@ "cryptoRequiresSignIn": "Faça login para pagar com criptomoedas.", "resendEmail": "Reenviar e-mail", "emailResent": "Enviamos o e-mail novamente", - "resendEmailError": "Não foi possível enviar o e-mail. Tente novamente mais tarde." + "resendEmailError": "Não foi possível enviar o e-mail. Tente novamente mais tarde.", + "checkEmail": { + "title": "Verifique seu e-mail", + "description": "Seu pagamento foi concluído. Enviamos um link para concluir a configuração da sua conta para:", + "spamHint": "Não encontrou? Verifique sua pasta de spam." + } } }, "urgentCheckout": { @@ -1582,6 +1587,30 @@ } } }, + "accountSetup": { + "form": { + "title": "Configure sua conta", + "description": "Crie uma senha para concluir a configuração da sua conta Internxt.", + "confirmPassword": "Confirmar senha", + "passwordsDoNotMatch": "As senhas não coincidem", + "submit": "Criar conta", + "genericError": "Algo deu errado ao configurar sua conta. Tente novamente." + }, + "invalidLink": { + "title": "Este link não é mais válido", + "description": "O link expirou ou já foi usado. Informe seu e-mail e enviaremos um novo.", + "emailLabel": "E-mail", + "resendButton": "Enviar um novo link", + "resendError": "Não foi possível enviar o e-mail. Tente novamente.", + "emailSentTitle": "Verifique seu e-mail", + "emailSentDescription": "Se {{email}} tiver uma conta aguardando configuração, você receberá um novo link em breve." + }, + "accountAlreadyExists": { + "title": "Sua conta já está configurada", + "description": "Esta conta já existe. Faça login com seu e-mail e senha para acessá-la.", + "loginButton": "Ir para o login" + } + }, "blockedAccount": { "title": "Confirme que é você", "text1": "Houve tentativas de login malsucedidas demais.", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 09ec045291..a8ff02225b 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -287,7 +287,12 @@ "cryptoRequiresSignIn": "Войдите, чтобы оплатить криптовалютой.", "resendEmail": "Отправить письмо ещё раз", "emailResent": "Мы отправили письмо ещё раз", - "resendEmailError": "Не удалось отправить письмо. Повторите попытку позже." + "resendEmailError": "Не удалось отправить письмо. Повторите попытку позже.", + "checkEmail": { + "title": "Проверьте почту", + "description": "Оплата прошла успешно. Мы отправили ссылку для завершения настройки аккаунта на адрес:", + "spamHint": "Не нашли письмо? Проверьте папку «Спам»." + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 3d804dd383..952bd37002 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -308,7 +308,12 @@ "cryptoRequiresSignIn": "請登入後使用加密貨幣付款。", "resendEmail": "重新寄送郵件", "emailResent": "我們已重新寄送郵件", - "resendEmailError": "無法寄送郵件,請稍後再試。" + "resendEmailError": "無法寄送郵件,請稍後再試。", + "checkEmail": { + "title": "請查看你的電子郵件", + "description": "付款成功。我們已將完成帳戶設定的連結寄送至:", + "spamHint": "找不到郵件?請檢查垃圾郵件資料夾。" + } } }, "urgentCheckout": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 180341a9ec..ab159b1688 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -308,7 +308,12 @@ "cryptoRequiresSignIn": "请登录后使用加密货币付款。", "resendEmail": "重新发送邮件", "emailResent": "我们已重新发送邮件", - "resendEmailError": "无法发送邮件,请稍后重试。" + "resendEmailError": "无法发送邮件,请稍后重试。", + "checkEmail": { + "title": "请查看你的电子邮件", + "description": "付款成功。我们已将完成账户设置的链接发送至:", + "spamHint": "找不到邮件?请检查垃圾邮件文件夹。" + } } }, "urgentCheckout": { diff --git a/src/app/routes/paths.json b/src/app/routes/paths.json index f21ed58f5e..ce78233a25 100644 --- a/src/app/routes/paths.json +++ b/src/app/routes/paths.json @@ -154,7 +154,7 @@ "layout": "empty", "path": "/checkout/success", "exact": false, - "auth": true + "auth": false }, { "id": "checkout-cancel", diff --git a/src/views/AccountSetup/AccountSetupView.test.tsx b/src/views/AccountSetup/AccountSetupView.test.tsx index cfbe145a9b..9062fd1337 100644 --- a/src/views/AccountSetup/AccountSetupView.test.tsx +++ b/src/views/AccountSetup/AccountSetupView.test.tsx @@ -27,7 +27,7 @@ vi.mock('services/encrypted-storage.service', () => ({ default: { setToken: vi.fn(), getToken: vi.fn(), getUser: vi.fn(), clear: vi.fn() }, })); vi.mock('services/local-storage.service', () => ({ - default: { get: vi.fn(), set: vi.fn(), clear: vi.fn() }, + default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), clearExcept: vi.fn() }, })); vi.mock('app/store/slices/user', () => ({ initializeUserThunk: vi.fn(), diff --git a/src/views/AccountSetup/services/completeAccountSetup.test.ts b/src/views/AccountSetup/services/completeAccountSetup.test.ts index 6322e71fe3..2401bdab0e 100644 --- a/src/views/AccountSetup/services/completeAccountSetup.test.ts +++ b/src/views/AccountSetup/services/completeAccountSetup.test.ts @@ -1,5 +1,6 @@ import { CompleteAccountSetupPayload } from '@internxt/sdk/dist/auth/types'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { LocalStorageItem } from 'app/core/types'; import { decryptText, decryptTextWithKey, passToHash } from 'app/crypto/services/utils'; import { planThunks } from 'app/store/slices/plan'; import { userThunks } from 'app/store/slices/user'; @@ -7,6 +8,7 @@ import { validateMnemonic } from 'bip39'; import { Buffer } from 'node:buffer'; import encryptedStorageService from 'services/encrypted-storage.service'; import envService from 'services/env.service'; +import localStorageService from 'services/local-storage.service'; import { getCompleteAccountSetupResponse } from 'testUtils/fixtures/accountSetup.fixtures'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { completeAccountSetup } from './completeAccountSetup'; @@ -23,7 +25,7 @@ vi.mock('services/encrypted-storage.service', () => ({ default: { setToken: vi.fn(), getToken: vi.fn(), getUser: vi.fn(), clear: vi.fn() }, })); vi.mock('services/local-storage.service', () => ({ - default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), clearExcept: vi.fn() }, + default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), clearExcept: vi.fn(), removeItem: vi.fn() }, })); vi.mock('app/store/slices/user', () => ({ initializeUserThunk: vi.fn(), @@ -101,5 +103,13 @@ describe('Completing the setup of a paid account', () => { ); expect(encryptedStorageService.setToken).not.toHaveBeenCalled(); expect(userThunks.setUserThunk).not.toHaveBeenCalled(); + expect(localStorageService.clearExcept).not.toHaveBeenCalled(); + }); + + test('When the account is set up, then the checkout "check your email" screen is no longer shown on a later visit', async () => { + await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() }); + + const [keysToPreserve] = vi.mocked(localStorageService.clearExcept).mock.calls[0]; + expect(keysToPreserve).not.toContain(LocalStorageItem.CheckoutAccountSetupEmail); }); }); diff --git a/src/views/AccountSetup/services/completeAccountSetup.ts b/src/views/AccountSetup/services/completeAccountSetup.ts index 17df26a60c..6595992209 100644 --- a/src/views/AccountSetup/services/completeAccountSetup.ts +++ b/src/views/AccountSetup/services/completeAccountSetup.ts @@ -20,7 +20,7 @@ export const completeAccountSetup = async ({ setupToken, password, dispatch }: C const { user, token, newToken } = await authClient.completeAccountSetup({ token: setupToken, ...credentials }); const mnemonic = decryptTextWithKey(user.mnemonic, password); - return startNewAccountSession({ + const session = await startNewAccountSession({ registeredUser: { xUser: { ...user, mnemonic } as unknown as UserSettings, xToken: token, @@ -31,4 +31,6 @@ export const completeAccountSetup = async ({ setupToken, password, dispatch }: C redeemCodeObject: false, dispatch, }); + + return session; }; diff --git a/src/views/Checkout/views/CheckoutSuccessView.test.tsx b/src/views/Checkout/views/CheckoutSuccessView.test.tsx index 30f12e29c6..a3ca896958 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.test.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.test.tsx @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import CheckoutSuccessView from './CheckoutSuccessView'; -import { AppView } from 'app/core/types'; +import { AppView, LocalStorageItem } from 'app/core/types'; import { PURCHASE_LOCAL_STORAGE_ITEMS } from 'services/storage-keys'; const mocks = vi.hoisted(() => ({ localStorageRemoveItem: vi.fn(), + localStorageGet: vi.fn(), + getSessionToken: vi.fn(), navigationPush: vi.fn(), retrievePaymentIntent: vi.fn(), retrieveSetupIntent: vi.fn(), @@ -19,7 +21,13 @@ const mocks = vi.hoisted(() => ({ vi.mock('app/store/hooks', () => ({ useAppDispatch: () => vi.fn() })); vi.mock('services/navigation.service', () => ({ default: { push: mocks.navigationPush } })); -vi.mock('services/local-storage.service', () => ({ default: { removeItem: mocks.localStorageRemoveItem } })); +vi.mock('services/local-storage.service', () => ({ + default: { removeItem: mocks.localStorageRemoveItem, get: mocks.localStorageGet }, +})); +vi.mock('services/encrypted-storage.service', () => ({ default: { getToken: mocks.getSessionToken } })); +vi.mock('../components/ResendAccountSetupEmailButton', () => ({ + ResendAccountSetupEmailButton: () => , +})); vi.mock('app/analytics/meta.service', () => ({ default: { trackPurchase: mocks.metaTrackPurchase } })); vi.mock('app/analytics/ga.service', () => ({ default: { trackPurchase: mocks.gaTrackPurchase } })); vi.mock('app/analytics/impact.service', () => ({ trackPaymentConversion: mocks.trackPaymentConversion })); @@ -94,9 +102,44 @@ const expectCheckoutFinished = () => { describe('Checkout success view', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.getSessionToken.mockReturnValue('session_token'); + mocks.localStorageGet.mockReturnValue(null); landOn(SUCCESS_PATH); }); + describe('When a buyer without an account finishes paying', () => { + beforeEach(() => { + mocks.getSessionToken.mockReturnValue(undefined); + mocks.localStorageGet.mockImplementation((key: string) => + key === LocalStorageItem.CheckoutAccountSetupEmail ? 'new.buyer@internxt.com' : null, + ); + }); + + test('then they are asked to check the email the setup link was sent to, and they stay on the page', async () => { + render(); + + expect(await screen.findByText('checkout.accountSetup.checkEmail.title')).toBeInTheDocument(); + expect(screen.getByText('new.buyer@internxt.com')).toBeInTheDocument(); + expect(screen.getByText('checkout.accountSetup.checkEmail.spamHint')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'checkout.accountSetup.resendEmail' })).toBeInTheDocument(); + await waitFor(() => expectPurchaseTracked()); + PURCHASE_LOCAL_STORAGE_ITEMS.forEach((item) => expect(mocks.localStorageRemoveItem).toHaveBeenCalledWith(item)); + expect(mocks.userStoragePolling).not.toHaveBeenCalled(); + expect(mocks.navigationPush).not.toHaveBeenCalled(); + }); + }); + + describe('When a logged-in user finishes paying after a previous purchase without an account', () => { + test('then they are taken to Drive instead of being asked to check their email', async () => { + mocks.localStorageGet.mockReturnValue('old.buyer@internxt.com'); + + await renderAndWaitForRedirect(); + + expect(screen.queryByText('checkout.accountSetup.checkEmail.title')).not.toBeInTheDocument(); + expectCheckoutFinished(); + }); + }); + describe('When the customer paid without Stripe redirecting back (crypto, lifetime with a 100% off coupon)', () => { test('then the purchase is tracked without asking Stripe and the checkout is finished', async () => { await renderAndWaitForRedirect(); diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index bb111e6913..699613d2c5 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -1,23 +1,45 @@ import useEffectAsync from 'hooks/useEffectAsync'; import navigationService from 'services/navigation.service'; -import { AppView } from 'app/core/types'; +import { AppView, LocalStorageItem } from 'app/core/types'; import { useAppDispatch } from 'app/store/hooks'; -import { useCallback, useRef } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { CheckCircle } from '@phosphor-icons/react'; import localStorageService from 'services/local-storage.service'; +import encryptedStorageService from 'services/encrypted-storage.service'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import { trackPaymentConversion } from 'app/analytics/impact.service'; import gaService from 'app/analytics/ga.service'; import { PURCHASE_LOCAL_STORAGE_ITEMS } from 'services/storage-keys'; import metaService from 'app/analytics/meta.service'; import { userStoragePolling } from 'utils/userStoragePolling.utils'; -import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { paymentService } from '../services'; import envService from 'services/env.service'; +import { ResendAccountSetupEmailButton } from '../components/ResendAccountSetupEmailButton'; export function removePaymentsStorage() { PURCHASE_LOCAL_STORAGE_ITEMS.forEach((item) => localStorageService.removeItem(item)); } +const AccountSetupEmailSentCard = ({ email }: { email: string }) => { + const { translate } = useTranslationContext(); + + return ( +
+
+ +
+

{translate('checkout.accountSetup.checkEmail.title')}

+

{translate('checkout.accountSetup.checkEmail.description')}

+

{email}

+
+ +

{translate('checkout.accountSetup.checkEmail.spamHint')}

+
+
+ ); +}; + const hasStripeReportedPaymentFailed = async (): Promise => { const params = new URLSearchParams(globalThis.location.search); const setupIntentSecret = params.get('setup_intent_client_secret'); @@ -56,7 +78,12 @@ const CheckoutSuccessView = (): JSX.Element => { const dispatch = useAppDispatch(); const { translate } = useTranslationContext(); const hasTrackedRef = useRef(false); + const hasSession = Boolean(encryptedStorageService.getToken()); + const [accountSetupEmail] = useState(() => + hasSession ? null : localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail), + ); + // `startNewAccountSession` already clears the storage, so no need to clear the CheckoutAccountSetupEmail variable here const onCheckoutSuccess = useCallback(async () => { if (hasTrackedRef.current) { return; @@ -76,6 +103,10 @@ const CheckoutSuccessView = (): JSX.Element => { console.error('Analytics error:', err); } + if (accountSetupEmail) { + return; + } + userStoragePolling(); navigationService.push(AppView.Drive); @@ -83,6 +114,10 @@ const CheckoutSuccessView = (): JSX.Element => { useEffectAsync(onCheckoutSuccess, []); + if (accountSetupEmail) { + return ; + } + return
; };