From 693faedec95e8619465da48dfa82780fbcf15838 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 29 Sep 2026 11:19:41 +0200 Subject: [PATCH 1/2] feat(checkout): add the check-your-email screen for passwordless buyers Show a dedicated success screen for buyers who paid without an account, asking them to check their email to finish setting it up (with a resend option), and open the checkout success route to unauthenticated buyers. --- src/app/i18n/locales/de.json | 7 +- src/app/i18n/locales/en.json | 7 +- src/app/i18n/locales/es.json | 7 +- src/app/i18n/locales/fr.json | 7 +- src/app/i18n/locales/it.json | 7 +- src/app/i18n/locales/ru.json | 7 +- src/app/i18n/locales/tw.json | 7 +- src/app/i18n/locales/zh.json | 7 +- src/app/routes/paths.json | 2 +- .../views/CheckoutSuccessView.test.tsx | 79 +++++++++++++++++++ .../Checkout/views/CheckoutSuccessView.tsx | 41 +++++++++- 11 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 src/views/Checkout/views/CheckoutSuccessView.test.tsx diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 3f9edfadac..f2dfa1a565 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -278,7 +278,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 9054c04f91..6ad63a5b63 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -299,7 +299,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 a55ae66bb7..bf257c2c92 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -278,7 +278,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 5781558573..8fd33edf0e 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -278,7 +278,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 faca128f93..bf6bc9799b 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -299,7 +299,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/ru.json b/src/app/i18n/locales/ru.json index 04daa5c429..f31a91f1aa 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -278,7 +278,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 c09c660aef..a4a5d44cec 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -299,7 +299,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 3243adbb08..d56a5243e9 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -299,7 +299,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 7ab0f7164d..14cd8e8171 100644 --- a/src/app/routes/paths.json +++ b/src/app/routes/paths.json @@ -147,7 +147,7 @@ "layout": "empty", "path": "/checkout/success", "exact": false, - "auth": true + "auth": false }, { "id": "checkout-cancel", diff --git a/src/views/Checkout/views/CheckoutSuccessView.test.tsx b/src/views/Checkout/views/CheckoutSuccessView.test.tsx new file mode 100644 index 0000000000..47d8275d1b --- /dev/null +++ b/src/views/Checkout/views/CheckoutSuccessView.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { AppView, LocalStorageItem } from 'app/core/types'; +import enTranslations from 'app/i18n/locales/en.json'; +import encryptedStorageService from 'services/encrypted-storage.service'; +import localStorageService from 'services/local-storage.service'; +import navigationService from 'services/navigation.service'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import CheckoutSuccessView from './CheckoutSuccessView'; + +const translate = (key: string) => { + const value = key.split('.').reduce((accumulator, part) => accumulator?.[part], enTranslations); + + if (typeof value !== 'string') { + throw new Error(`Missing translation for "${key}"`); + } + + return value; +}; + +vi.mock('app/i18n/provider/TranslationProvider', () => ({ + useTranslationContext: () => ({ translate }), +})); + +vi.mock('app/store/hooks', () => ({ + useAppDispatch: () => vi.fn(), +})); + +vi.mock('app/analytics/ga.service', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, default: { ...original.default, trackPurchase: vi.fn() } }; +}); +vi.mock('app/analytics/meta.service', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, default: { ...original.default, trackPurchase: vi.fn() } }; +}); +vi.mock('app/analytics/impact.service', async (importOriginal) => ({ + ...(await importOriginal()), + trackPaymentConversion: vi.fn(), +})); + +vi.mock('utils/userStoragePolling.utils', () => ({ + userStoragePolling: vi.fn(), +})); + +const { checkEmail } = enTranslations.checkout.accountSetup; + +describe('Checkout success', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.spyOn(navigationService, 'push').mockImplementation(() => undefined); + }); + + test('When a buyer without an account finishes paying, then they are asked to check the email the setup link was sent to', async () => { + vi.spyOn(encryptedStorageService, 'getToken').mockReturnValue(undefined); + localStorageService.set(LocalStorageItem.CheckoutAccountSetupEmail, 'new.buyer@internxt.com'); + localStorageService.set(LocalStorageItem.SubscriptionID, 'sub_123'); + + render(); + + expect(await screen.findByText(checkEmail.title)).toBeInTheDocument(); + expect(screen.getByText('new.buyer@internxt.com')).toBeInTheDocument(); + expect(screen.getByText(checkEmail.spamHint)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: enTranslations.checkout.accountSetup.resendEmail })).toBeInTheDocument(); + await waitFor(() => expect(localStorageService.get(LocalStorageItem.SubscriptionID)).toBeNull()); + expect(navigationService.push).not.toHaveBeenCalled(); + }); + + test('When a logged-in user finishes paying, then they are taken to Drive', async () => { + vi.spyOn(encryptedStorageService, 'getToken').mockReturnValue('session_token'); + localStorageService.set(LocalStorageItem.CheckoutAccountSetupEmail, 'old.buyer@internxt.com'); + + render(); + + await waitFor(() => expect(navigationService.push).toHaveBeenCalledWith(AppView.Drive)); + expect(screen.queryByText(checkEmail.title)).not.toBeInTheDocument(); + expect(localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail)).toBeNull(); + }); +}); diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index 3f1ba79bc5..bf4cecfdb5 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -2,12 +2,16 @@ import useEffectAsync from 'hooks/useEffectAsync'; import navigationService from 'services/navigation.service'; 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 metaService from 'app/analytics/meta.service'; import { userStoragePolling } from 'utils/userStoragePolling.utils'; +import { ResendAccountSetupEmailButton } from '../components/ResendAccountSetupEmailButton'; export function removePaymentsStorage() { localStorageService.removeItem(LocalStorageItem.SubscriptionID); @@ -19,9 +23,32 @@ export function removePaymentsStorage() { localStorageService.removeItem(LocalStorageItem.CouponCode); } +const CheckYourEmail = ({ email }: { email: string }) => { + const { translate } = useTranslationContext(); + + return ( +
+
+ +
+

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

+

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

+

{email}

+
+ +

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

+
+
+ ); +}; + const CheckoutSuccessView = (): JSX.Element => { const dispatch = useAppDispatch(); const hasTrackedRef = useRef(false); + const hasSession = Boolean(encryptedStorageService.getToken()); + const [accountSetupEmail] = useState(() => + hasSession ? null : localStorageService.get(LocalStorageItem.CheckoutAccountSetupEmail), + ); const onCheckoutSuccess = useCallback(async () => { if (hasTrackedRef.current) { @@ -40,6 +67,14 @@ const CheckoutSuccessView = (): JSX.Element => { console.error('Analytics error:', err); } + if (accountSetupEmail) { + return; + } + + if (hasSession) { + localStorageService.removeItem(LocalStorageItem.CheckoutAccountSetupEmail); + } + userStoragePolling(); navigationService.push(AppView.Drive); @@ -47,6 +82,10 @@ const CheckoutSuccessView = (): JSX.Element => { useEffectAsync(onCheckoutSuccess, []); + if (accountSetupEmail) { + return ; + } + return
; }; From 058e7dfa63438abeb4b17cd23e2f5060e5d8c68e Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Mon, 5 Oct 2026 13:15:02 +0200 Subject: [PATCH 2/2] refactor: rename component --- .../services/completeAccountSetup.test.ts | 11 ++++++++++- .../AccountSetup/services/completeAccountSetup.ts | 4 +++- src/views/Checkout/views/CheckoutSuccessView.tsx | 9 +++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/views/AccountSetup/services/completeAccountSetup.test.ts b/src/views/AccountSetup/services/completeAccountSetup.test.ts index 9c81b2f688..004ce7f9ff 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() }, + default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), removeItem: vi.fn() }, })); vi.mock('app/store/slices/user', () => ({ initializeUserThunk: vi.fn(), @@ -101,5 +103,12 @@ describe('Completing the setup of a paid account', () => { ); expect(encryptedStorageService.setToken).not.toHaveBeenCalled(); expect(userThunks.setUserThunk).not.toHaveBeenCalled(); + expect(localStorageService.removeItem).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() }); + + expect(localStorageService.removeItem).toHaveBeenCalledWith(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.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index bf4cecfdb5..3c2a74a731 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -23,7 +23,7 @@ export function removePaymentsStorage() { localStorageService.removeItem(LocalStorageItem.CouponCode); } -const CheckYourEmail = ({ email }: { email: string }) => { +const AccountSetupEmailSentCard = ({ email }: { email: string }) => { const { translate } = useTranslationContext(); return ( @@ -50,6 +50,7 @@ const CheckoutSuccessView = (): JSX.Element => { 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; @@ -71,10 +72,6 @@ const CheckoutSuccessView = (): JSX.Element => { return; } - if (hasSession) { - localStorageService.removeItem(LocalStorageItem.CheckoutAccountSetupEmail); - } - userStoragePolling(); navigationService.push(AppView.Drive); @@ -83,7 +80,7 @@ const CheckoutSuccessView = (): JSX.Element => { useEffectAsync(onCheckoutSuccess, []); if (accountSetupEmail) { - return ; + return ; } return
;