From ed36848a9a11ddb80773ced8cacfaad498904c08 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 28 Jul 2026 15:08:41 +0100 Subject: [PATCH 1/2] test(i18n): collapse 39 hand-rolled intl wrappers into one shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every intl-using suite defined its own NextIntlClientProvider wrapper with the same locale/messages/timeZone triple. src/test-utils/intl.tsx now owns it and exports the three shapes the call sites actually need: IntlWrapper (composable, for suites that nest QueryClientProvider or a context provider), renderWithIntl (the common wrapper-option case), and renderHookWithIntl. Two suites had omitted `timeZone`, so next-intl fell back to the runner's system zone and their date formatting was machine-dependent; the shared wrapper pins UTC for everyone. renderHookWithIntl exists so useSumsubKycFlow.test.ts — a .ts file that could not use JSX — can drop its React.createElement workaround and the `eslint-disable react/no-children-prop` that came with it. ClientProviders.test.tsx deliberately keeps its own reference: it asserts on provider ORDERING, so the provider is the subject under test, not a fixture. --- .../__tests__/add-money-states.test.tsx | 6 ++-- .../qr-pay/__tests__/qr-pay-states.test.tsx | 7 ++-- .../__tests__/withdraw-states.test.tsx | 7 ++-- .../crypto-withdraw-confirm.test.tsx | 9 +---- .../__tests__/MantecaAddMoney.test.tsx | 9 +---- .../__tests__/MantecaPixQrDeposit.test.tsx | 10 ++---- .../AddWithdrawCountriesList.test.tsx | 10 ++---- .../__tests__/AddWithdrawRouterView.test.tsx | 7 ++-- .../Badges/__tests__/BadgeEarnToast.test.tsx | 10 ++---- .../Badges/__tests__/BadgesRow.test.tsx | 9 +---- .../ApplicationStatusScreen.test.tsx | 7 +--- .../CardCountryConfirmScreen.test.tsx | 8 +---- .../Card/__tests__/CardFace.test.tsx | 8 +---- .../__tests__/PhysicalCardScreen.test.tsx | 9 +---- .../__tests__/SendLinkActionList.test.tsx | 7 ++-- .../Claim/__tests__/claim-states.test.tsx | 7 ++-- .../__tests__/GeneralRecipientInput.test.tsx | 9 +---- .../Global/QRScanner/__tests__/index.test.tsx | 9 +---- .../__tests__/ReEnableModal.test.tsx | 8 +---- .../__tests__/SupportDrawer.test.tsx | 8 +---- .../Home/__tests__/ActivationCTAs.test.tsx | 10 ++---- .../EnableAutoBalanceBanner.test.tsx | 10 ++---- .../Kyc/__tests__/SumsubKycWrapper.test.tsx | 10 ++---- .../KycActionRequired.rejectCopy.test.tsx | 9 +---- .../Kyc/states/__tests__/KycStates.test.tsx | 8 +---- .../__tests__/kycCallbackHygiene.test.tsx | 8 +---- .../Request/__tests__/request-states.test.tsx | 9 +---- .../Send/__tests__/send-states.test.tsx | 7 ++-- .../__tests__/Initial.link.send.view.test.tsx | 6 ++-- .../__tests__/DeleteAccountButton.test.tsx | 8 +---- .../__tests__/CardUsdAbroadNotice.test.tsx | 8 +---- .../__tests__/LocalRailNudge.test.tsx | 8 +---- .../__tests__/TransactionCard.test.tsx | 8 +---- .../__tests__/CancelDepositActions.test.tsx | 10 ++---- .../BridgeDepositInstructions.test.tsx | 8 +---- ...PaymentRows.settlement-adjustment.test.tsx | 9 +---- .../__tests__/RainCooldownContext.test.tsx | 7 ++-- .../PaymentMethodActionList.test.tsx | 10 ++---- src/hooks/__tests__/useSumsubKycFlow.test.ts | 13 ++----- src/test-utils/intl.tsx | 36 +++++++++++++++++++ 40 files changed, 102 insertions(+), 264 deletions(-) create mode 100644 src/test-utils/intl.tsx diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 1c9e6f0c0a..467d5cf4ad 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -16,8 +16,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars, react/display-name */ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { NextIntlClientProvider } from 'next-intl' import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -897,9 +897,9 @@ function createQueryClient() { function renderWithProviders(component: React.ReactElement) { const queryClient = createQueryClient() return render( - + {component} - + ) } diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index 09d93db3ad..742d730b96 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -10,9 +10,8 @@ import React from 'react' import posthog from 'posthog-js' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' import { parseUnits } from 'viem' import type { RailCapability } from '@/types/capabilities' @@ -536,13 +535,13 @@ function renderQrPay(params: Record = {}) { } return render( - + - + ) } diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 289a25f3e9..356ccb7c75 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -9,9 +9,8 @@ */ import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' import { parseUnits } from 'viem' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -226,11 +225,11 @@ function renderWithdraw(params: Record = {}) { setSearchParams(params) const queryClient = createQueryClient() return render( - + - + ) } diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index 563047ffbf..12a16931d1 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -14,8 +14,7 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import enMessages from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' // ---------- module-level mocks ---------- @@ -252,12 +251,6 @@ jest.mock('@/features/payments/shared/hooks/usePaymentRecorder', () => ({ import WithdrawCryptoPage from '../page' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - const render = (ui: React.ReactElement, options?: Omit[1], 'wrapper'>) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) diff --git a/src/components/AddMoney/components/__tests__/MantecaAddMoney.test.tsx b/src/components/AddMoney/components/__tests__/MantecaAddMoney.test.tsx index 4bd9f4f8f4..98c143596e 100644 --- a/src/components/AddMoney/components/__tests__/MantecaAddMoney.test.tsx +++ b/src/components/AddMoney/components/__tests__/MantecaAddMoney.test.tsx @@ -11,17 +11,10 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import enMessages from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' // jest.mock calls are hoisted above this import, so the mocks below apply to it import MantecaAddMoney from '../MantecaAddMoney' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - const render = (ui: React.ReactElement, options?: Omit[1], 'wrapper'>) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx index 0a80826169..5149804217 100644 --- a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -8,15 +8,9 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' -const render = (ui: React.ReactElement) => - rtlRender( - - {ui} - - ) +const render = (ui: React.ReactElement) => rtlRender({ui}) const mockUseMantecaDepositPolling = jest.fn() jest.mock('@/components/AddMoney/hooks/useMantecaDepositPolling', () => ({ diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index ede4199b00..832a3d3acf 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -16,17 +16,11 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, within } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import AddWithdrawCountriesList from '../AddWithdrawCountriesList' import underMaintenanceConfig from '@/config/underMaintenance.config' -const render = (ui: React.ReactElement) => - rtlRender( - - {ui} - - ) +const render = (ui: React.ReactElement) => rtlRender({ui}) // ---- routing ---- const mockPush = jest.fn() diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx index 00f5d81235..28eef3d5e1 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx @@ -13,8 +13,7 @@ */ import React, { useEffect } from 'react' import { render, screen, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' const mockRouterPush = jest.fn() jest.mock('next/navigation', () => ({ @@ -137,12 +136,12 @@ function SelectedMethodProbe() { function Harness({ user }: { user: MockUser }) { mockUser = user return ( - + - + ) } diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx index 033af47fcd..ff556bf1c1 100644 --- a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -1,14 +1,8 @@ import { render as rtlRender, screen, act } from '@testing-library/react' -import type { ComponentProps, ReactNode } from 'react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' +import type { ComponentProps } from 'react' import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) // next/navigation — mutable pathname so we can exercise the /home gate; stable diff --git a/src/components/Badges/__tests__/BadgesRow.test.tsx b/src/components/Badges/__tests__/BadgesRow.test.tsx index b78cb2755a..fb79e5560f 100644 --- a/src/components/Badges/__tests__/BadgesRow.test.tsx +++ b/src/components/Badges/__tests__/BadgesRow.test.tsx @@ -1,16 +1,9 @@ import { render as rtlRender } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import React from 'react' -import { NextIntlClientProvider } from 'next-intl' -import enMessages from '@/i18n/app/messages/en.json' import type { ComponentProps } from 'react' import BadgesRow from '@/components/Badges/BadgesRow' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - const render = (ui: React.ReactElement, options?: Omit[1], 'wrapper'>) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) diff --git a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx index 2f3bddf37c..6a655b7f24 100644 --- a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx +++ b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx @@ -9,15 +9,10 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' +import { IntlWrapper } from '@/test-utils/intl' import en from '@/i18n/app/messages/en.json' import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) // NavHeader reads useAuth; stub it so the presentational screen renders alone. diff --git a/src/components/Card/__tests__/CardCountryConfirmScreen.test.tsx b/src/components/Card/__tests__/CardCountryConfirmScreen.test.tsx index eb41b18d79..ac55ddde16 100644 --- a/src/components/Card/__tests__/CardCountryConfirmScreen.test.tsx +++ b/src/components/Card/__tests__/CardCountryConfirmScreen.test.tsx @@ -10,15 +10,9 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import CardCountryConfirmScreen from '@/components/Card/CardCountryConfirmScreen' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) // NavHeader reads useAuth; stub it so the presentational screen renders alone. diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx index 5624667e16..83516ac401 100644 --- a/src/components/Card/__tests__/CardFace.test.tsx +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -8,15 +8,9 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) const revealed: RevealedCardDetails = { diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx index 5dfb9c8f27..95a0d1e1f4 100644 --- a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -7,18 +7,11 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import enMessages from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import PhysicalCardScreen from '@/components/Card/PhysicalCardScreen' import { rainApi } from '@/services/rain' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - const render = (ui: React.ReactElement, options?: Omit[1], 'wrapper'>) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) diff --git a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx index af26bf0040..5a627b90c2 100644 --- a/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx +++ b/src/components/Claim/Link/__tests__/SendLinkActionList.test.tsx @@ -8,8 +8,7 @@ */ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' // ---------- module-level mocks (before importing the component) ---------- @@ -129,9 +128,9 @@ const claimLinkData = { function renderList() { return render( - + - + ) } diff --git a/src/components/Claim/__tests__/claim-states.test.tsx b/src/components/Claim/__tests__/claim-states.test.tsx index 7672a923a1..1e4f07a9f2 100644 --- a/src/components/Claim/__tests__/claim-states.test.tsx +++ b/src/components/Claim/__tests__/claim-states.test.tsx @@ -10,9 +10,8 @@ */ import React from 'react' import { render, screen, waitFor } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -241,11 +240,11 @@ function createQueryClient() { function renderClaim() { const queryClient = createQueryClient() return render( - + - + ) } diff --git a/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx b/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx index 7472162a72..1b3bc38702 100644 --- a/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx +++ b/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx @@ -1,7 +1,5 @@ import { render as rtlRender, act } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import type { ReactNode } from 'react' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import GeneralRecipientInput from '../index' import * as bridgeUtils from '@/utils/bridge-accounts.utils' import * as sentryUtils from '@/utils/sentry.utils' @@ -9,11 +7,6 @@ import * as ens from '@/app/actions/ens' import type { RecipientType } from '@/interfaces/interfaces' import { validateEnsName } from '@/utils/general.utils' -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) // Test case type definition for better maintainability diff --git a/src/components/Global/QRScanner/__tests__/index.test.tsx b/src/components/Global/QRScanner/__tests__/index.test.tsx index 0a4e9929d4..38a4d4f173 100644 --- a/src/components/Global/QRScanner/__tests__/index.test.tsx +++ b/src/components/Global/QRScanner/__tests__/index.test.tsx @@ -10,8 +10,7 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, waitFor, act } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import enMessages from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { Clipboard } from '@capacitor/clipboard' import { clipboardHasStrings } from '@/utils/clipboard-detect' import { isAndroidNative } from '@/utils/capacitor' @@ -47,12 +46,6 @@ jest.mock('../useQRScanner', () => ({ import QRScanner from '../index' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - const render = (ui: React.ReactElement, options?: Omit[1], 'wrapper'>) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) diff --git a/src/components/Global/StaleCardApproval/__tests__/ReEnableModal.test.tsx b/src/components/Global/StaleCardApproval/__tests__/ReEnableModal.test.tsx index 02bfbec453..8b7631babb 100644 --- a/src/components/Global/StaleCardApproval/__tests__/ReEnableModal.test.tsx +++ b/src/components/Global/StaleCardApproval/__tests__/ReEnableModal.test.tsx @@ -9,15 +9,9 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { RAIN_STALE_APPROVAL_EVENT } from '@/services/rain' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) const mockGrant = jest.fn, []>() diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index b7da58c6ac..f9d1b38211 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -15,17 +15,11 @@ */ import React from 'react' import { render as rtlRender, screen, act, waitFor, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import SupportDrawer from '../index' import { isCapacitor } from '@/utils/capacitor' import { SUPPORT_EMAIL } from '@/constants/crisp' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) const mockUseCrispUserData = jest.fn() diff --git a/src/components/Home/__tests__/ActivationCTAs.test.tsx b/src/components/Home/__tests__/ActivationCTAs.test.tsx index 3a353113c5..f2c6123986 100644 --- a/src/components/Home/__tests__/ActivationCTAs.test.tsx +++ b/src/components/Home/__tests__/ActivationCTAs.test.tsx @@ -13,14 +13,8 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' - -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) +import { IntlWrapper } from '@/test-utils/intl' + const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) let mockRails: Array<{ diff --git a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx index 846ff53392..967ce755b9 100644 --- a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx +++ b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx @@ -16,14 +16,8 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' - -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) +import { IntlWrapper } from '@/test-utils/intl' + const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) import type { GrantSessionKeyError } from '@/hooks/wallet/useGrantSessionKey' diff --git a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx index 535365a962..86f00ee3ee 100644 --- a/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx +++ b/src/components/Kyc/__tests__/SumsubKycWrapper.test.tsx @@ -1,14 +1,8 @@ import { act, render as rtlRender, screen, waitFor, type RenderOptions } from '@testing-library/react' -import { useEffect, useState, type ReactElement, type ReactNode } from 'react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' +import { useEffect, useState, type ReactElement } from 'react' import { SumsubKycWrapper } from '../SumsubKycWrapper' -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: ReactElement, options?: RenderOptions) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) const capture = jest.fn() diff --git a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx index eca6e73c90..b398d5fbbe 100644 --- a/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx +++ b/src/components/Kyc/states/__tests__/KycActionRequired.rejectCopy.test.tsx @@ -1,7 +1,5 @@ import { render as rtlRender, screen } from '@testing-library/react' -import { type ReactNode } from 'react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { KycActionRequired } from '../KycActionRequired' // Integration-level companion to KycStates.test.tsx: that suite mocks @@ -10,11 +8,6 @@ import { KycActionRequired } from '../KycActionRequired' // DUPLICATE_EMAIL → "Email already in use" mapping (the whole point of the // precedence fix) actually fails a test instead of shipping silently. -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) jest.mock('use-haptic', () => ({ diff --git a/src/components/Kyc/states/__tests__/KycStates.test.tsx b/src/components/Kyc/states/__tests__/KycStates.test.tsx index 809d8b2bb0..dc91f8d10e 100644 --- a/src/components/Kyc/states/__tests__/KycStates.test.tsx +++ b/src/components/Kyc/states/__tests__/KycStates.test.tsx @@ -1,15 +1,9 @@ import { fireEvent, render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { type ReactNode } from 'react' import { KycActionRequired } from '../KycActionRequired' import { KycFailed } from '../KycFailed' -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) jest.mock('use-haptic', () => ({ diff --git a/src/components/Kyc/states/__tests__/kycCallbackHygiene.test.tsx b/src/components/Kyc/states/__tests__/kycCallbackHygiene.test.tsx index d7e0e7e701..f242136b17 100644 --- a/src/components/Kyc/states/__tests__/kycCallbackHygiene.test.tsx +++ b/src/components/Kyc/states/__tests__/kycCallbackHygiene.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { type ReactNode } from 'react' import { KycActionRequired } from '../KycActionRequired' import { KycFailed } from '../KycFailed' @@ -11,11 +10,6 @@ import { KycFailed } from '../KycFailed' // KycRequiresDocuments with KycActionRequired, so this now covers the two // surviving rejection/resubmit states. -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) jest.mock('../../KYCStatusDrawerItem', () => ({ diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 36e803c4a4..35082dacef 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -9,9 +9,8 @@ */ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -332,12 +331,6 @@ function createQueryClient() { }) } -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - function renderCreateRequest(params: Record = {}) { setSearchParams(params) const queryClient = createQueryClient() diff --git a/src/components/Send/__tests__/send-states.test.tsx b/src/components/Send/__tests__/send-states.test.tsx index b7953d387e..3aa40ea28b 100644 --- a/src/components/Send/__tests__/send-states.test.tsx +++ b/src/components/Send/__tests__/send-states.test.tsx @@ -9,9 +9,8 @@ */ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' // ---------- module-level mocks (must be before imports that depend on them) ---------- @@ -193,11 +192,11 @@ function renderSend(params: Record = {}) { setSearchParams(params) const queryClient = createQueryClient() return render( - + - + ) } diff --git a/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx b/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx index be36681d49..8f89f80de4 100644 --- a/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx +++ b/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx @@ -11,9 +11,9 @@ */ import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { LinkSendFlowProvider, useLinkSendFlow } from '@/context/LinkSendFlowContext' -import { NextIntlClientProvider } from 'next-intl' import en from '@/i18n/app/messages/en.json' const COOLDOWN_MESSAGE = 'A previous withdrawal signature is still active. Try again in about 2 min.' @@ -121,14 +121,14 @@ const SetAmount = ({ amount }: { amount: string }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const viewTree = (amount: string) => ( - + - + ) const renderView = (amount: string) => { diff --git a/src/components/Settings/__tests__/DeleteAccountButton.test.tsx b/src/components/Settings/__tests__/DeleteAccountButton.test.tsx index a63d3c4795..88a56174b4 100644 --- a/src/components/Settings/__tests__/DeleteAccountButton.test.tsx +++ b/src/components/Settings/__tests__/DeleteAccountButton.test.tsx @@ -6,16 +6,10 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import DeleteAccountButton from '@/components/Settings/DeleteAccountButton' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) const mockLogout = jest.fn() diff --git a/src/components/TransactionDetails/__tests__/CardUsdAbroadNotice.test.tsx b/src/components/TransactionDetails/__tests__/CardUsdAbroadNotice.test.tsx index 0a5a5693bd..bbe13d4299 100644 --- a/src/components/TransactionDetails/__tests__/CardUsdAbroadNotice.test.tsx +++ b/src/components/TransactionDetails/__tests__/CardUsdAbroadNotice.test.tsx @@ -10,16 +10,10 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { CardUsdAbroadNotice } from '../provider-rows/CardUsdAbroadNotice' import { type TransactionDetails } from '../transactionTransformer' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) /** Minimal TransactionDetails shaped for the notice's inputs only. */ diff --git a/src/components/TransactionDetails/__tests__/LocalRailNudge.test.tsx b/src/components/TransactionDetails/__tests__/LocalRailNudge.test.tsx index de392a171e..28a0648898 100644 --- a/src/components/TransactionDetails/__tests__/LocalRailNudge.test.tsx +++ b/src/components/TransactionDetails/__tests__/LocalRailNudge.test.tsx @@ -13,16 +13,10 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import { LocalRailNudge } from '../provider-rows/LocalRailNudge' import { type TransactionDetails } from '../transactionTransformer' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) jest.mock('@/hooks/useCardMarkupRate', () => ({ diff --git a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx index db2a2bd06c..024c683863 100644 --- a/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx +++ b/src/components/TransactionDetails/__tests__/TransactionCard.test.tsx @@ -14,16 +14,10 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' import TransactionCard from '../TransactionCard' import { type TransactionDetails } from '../transactionTransformer' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) const push = jest.fn() diff --git a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx index 66e1ce37ae..9397be24bb 100644 --- a/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx +++ b/src/components/TransactionDetails/provider-actions/__tests__/CancelDepositActions.test.tsx @@ -10,14 +10,8 @@ */ import React from 'react' import { render as rtlRender, screen, fireEvent, waitFor, type RenderOptions } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' - -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) +import { IntlWrapper } from '@/test-utils/intl' + const render = (ui: React.ReactElement, options?: RenderOptions) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) const mockCancelOnramp = jest.fn() diff --git a/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx index 1a2012b57b..d1b3abf6df 100644 --- a/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx +++ b/src/components/TransactionDetails/provider-rows/__tests__/BridgeDepositInstructions.test.tsx @@ -10,14 +10,8 @@ */ import React from 'react' import { render as rtlRender, screen, type RenderOptions } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) const render = (ui: React.ReactElement, options?: RenderOptions) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) jest.mock('@/components/Payment/PaymentInfoRow', () => ({ diff --git a/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx b/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx index 88cfe87c21..654bf74bd3 100644 --- a/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx +++ b/src/components/TransactionDetails/provider-rows/__tests__/CardPaymentRows.settlement-adjustment.test.tsx @@ -10,8 +10,7 @@ */ import React from 'react' import { render as rtlRender, screen } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import enMessages from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' jest.mock('@/components/Payment/PaymentInfoRow', () => ({ PaymentInfoRow: ({ label, value }: { label: React.ReactNode; value: React.ReactNode }) => ( @@ -27,12 +26,6 @@ jest.mock('next/image', () => ({ __esModule: true, default: () => })) import { CardPaymentRows } from '../CardPaymentRows' import type { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer' -const IntlWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -) - const render = (ui: React.ReactElement, options?: Omit[1], 'wrapper'>) => rtlRender(ui, { wrapper: IntlWrapper, ...options }) diff --git a/src/context/__tests__/RainCooldownContext.test.tsx b/src/context/__tests__/RainCooldownContext.test.tsx index bd1885e4c8..6e32934a1e 100644 --- a/src/context/__tests__/RainCooldownContext.test.tsx +++ b/src/context/__tests__/RainCooldownContext.test.tsx @@ -1,7 +1,6 @@ import { act, renderHook } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' +import { IntlWrapper } from '@/test-utils/intl' import { ToastProvider } from '@/components/0_Bruddle/Toast' -import en from '@/i18n/app/messages/en.json' import { RainCooldownProvider, useRainCooldown } from '../RainCooldownContext' // Dispatch the same window event that `rainRequest` fires on a 425 from @@ -14,11 +13,11 @@ function fireCooldown(retryAfterSec: number, message = 'cooling down') { // The countdown pill renders localized copy, so the provider is required. const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} - + ) describe('RainCooldownContext', () => { diff --git a/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx b/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx index 628ae23bf2..c0c3b74ce6 100644 --- a/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx +++ b/src/features/payments/shared/components/__tests__/PaymentMethodActionList.test.tsx @@ -6,16 +6,10 @@ * a silent no-op (dead button). The card must only render when the caller can * honor it (i.e. provides the handler — the semantic-request flow does). */ -import React, { type ReactNode } from 'react' +import React from 'react' import { render as rtlRender, screen, fireEvent } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { IntlWrapper } from '@/test-utils/intl' -const IntlWrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -) const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) const mockRouterPush = jest.fn() diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index dbc76567e6..6e9632df82 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -1,7 +1,5 @@ -import React from 'react' -import { act, renderHook as rtlRenderHook, waitFor } from '@testing-library/react' -import { NextIntlClientProvider } from 'next-intl' -import en from '@/i18n/app/messages/en.json' +import { act, waitFor } from '@testing-library/react' +import { renderHookWithIntl as renderHook } from '@/test-utils/intl' import { useSumsubKycFlow } from '@/hooks/useSumsubKycFlow' import { initiateSumsubKyc } from '@/app/actions/sumsub' @@ -10,13 +8,6 @@ import { initiateSumsubKyc } from '@/app/actions/sumsub' // asserts hook behaviour (does onKycSuccess fire? is an error surfaced?) and nothing else. // The websocket mock captures the status-update handler so a test can simulate a // late/stale APPROVED event arriving after the flow has resolved. -// the hook calls useTranslations, so it needs an intl context to render at all -const IntlWrapper = ({ children }: { children: React.ReactNode }) => - // NextIntlClientProvider types `children` as a required prop, so createElement's third-arg - // form doesn't typecheck; this is a .ts file, so JSX isn't an option either. - // eslint-disable-next-line react/no-children-prop - React.createElement(NextIntlClientProvider, { locale: 'en', messages: en, timeZone: 'UTC', children }) -const renderHook = (cb: () => T) => rtlRenderHook(cb, { wrapper: IntlWrapper }) const mockWs: { handler?: (status: string, labels?: string[]) => void } = {} jest.mock('@/app/actions/sumsub', () => ({ diff --git a/src/test-utils/intl.tsx b/src/test-utils/intl.tsx new file mode 100644 index 0000000000..a064111897 --- /dev/null +++ b/src/test-utils/intl.tsx @@ -0,0 +1,36 @@ +import { render as rtlRender, renderHook as rtlRenderHook } from '@testing-library/react' +import type { RenderHookOptions, RenderOptions } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' +import type { ReactElement, ReactNode } from 'react' +import en from '@/i18n/app/messages/en.json' + +/** + * The single intl wrapper for tests, replacing ~33 hand-rolled copies. + * + * `timeZone` is pinned to UTC because next-intl otherwise falls back to the + * runner's system zone, which made date formatting machine-dependent in the two + * suites that had omitted it. + * + * Locale is always `en` — locale-specific rendering is covered by + * src/i18n/app/__tests__, not by component suites. + */ +export const IntlWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +/** Drop-in for RTL's `render`. Compose extra providers inside `ui`. */ +export function renderWithIntl(ui: ReactElement, options?: Omit) { + return rtlRender(ui, { wrapper: IntlWrapper, ...options }) +} + +/** Drop-in for RTL's `renderHook`. Callable from `.ts` files — no JSX at the + * call site, which is why the Sumsub hook suite previously needed a + * React.createElement dance plus an eslint-disable. */ +export function renderHookWithIntl( + callback: (props: Props) => Result, + options?: Omit, 'wrapper'> +) { + return rtlRenderHook(callback, { wrapper: IntlWrapper, ...options }) +} From 79396e1adb9b8fa988d19fad121eef5dbc8c4428 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 28 Jul 2026 16:35:57 +0100 Subject: [PATCH 2/2] chore(i18n): extract the copy jsx-no-literals can't see, and guard toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react/jsx-no-literals only inspects JSX children, so hardcoded copy kept reaching users through toasts, placeholders, aria-labels and alt text. A no-restricted-syntax selector now bans string literals as the first argument to toast.*, appended to the EXISTING array rather than a new config block — flat config replaces rule options when a later block names the same rule, so a second entry would have silently disabled the four router.back selectors. The rule is deliberately scoped to toast.* and not `throw new Error('…')`. Every throw site was traced to its catch handler: they all funnel through toFriendlyError to errors.genericSupport or a localized toast, so those strings never reach a user. They are Sentry breadcrumbs and issue-grouping keys, and translating them would fragment triage for no user-facing gain. Extracted: the four QRScanner toasts (three already had keys and simply weren't using them), both logout/login toasts and the two card-approval toasts, the waitlist email placeholder, the two "Peanut Logo" aria-labels (reusing the existing navigation.peanutLogoAlt), and two descriptive alt strings. The QRScanner payment-method row keeps its brand names verbatim — those are proper nouns — and only the EVM row, whose label is prose, moves to a key. NavHeader gains an optional `titleKey`, because the receipt page at /receipt/[entryId] is an async server component and this app has no server-side next-intl setup (locale is resolved entirely client-side by AppIntlProvider), so useTranslations is unavailable there. The mobile receipt page is a client component and uses plain `title` with t(). Also replaced the clipboard read's message match. "There is no data on the clipboard" is a hardcoded English literal inside @capacitor/clipboard's Java, not an OS string — so this was never a locale bug, but it did couple us to a dependency's copy, where a patch bump silently flips the branch. readClipboard() now classifies the rejection and returns a reason. The 'Enjoy Peanut!' sentinel is consolidated behind one isTestTransaction() predicate. It compares backend data, so it is locale-safe, but it was repeated in four places and one of them rendered the raw literal instead of the transaction.enjoyPeanut key that already existed and was already translated. The backend should own this flag — noted for follow-up. --- eslint.config.js | 15 +++++++ src/app/(mobile-ui)/receipt/page.tsx | 2 +- src/app/receipt/[entryId]/page.tsx | 2 +- .../Global/BackendErrorScreen/index.tsx | 13 ++---- src/components/Global/Banner/index.tsx | 2 +- .../Global/EasterEggModal/index.tsx | 2 +- src/components/Global/NavHeader/index.tsx | 13 +++++- src/components/Global/OfflineScreen/index.tsx | 13 ++---- src/components/Global/QRScanner/index.tsx | 44 +++++++++---------- src/components/Invites/JoinWaitlistPage.tsx | 2 +- .../TransactionDetails/TransactionCard.tsx | 12 ++--- .../TransactionDetailsHeaderCard.tsx | 13 +++--- .../TransactionDetailsReceipt.tsx | 4 +- src/context/authContext.tsx | 6 ++- src/hooks/useLogin.tsx | 4 +- src/hooks/wallet/useSendMoney.ts | 4 +- src/i18n/app/messages/en.json | 23 +++++++--- src/i18n/app/messages/es-419.json | 23 +++++++--- src/i18n/app/messages/pt-BR.json | 23 +++++++--- src/utils/clipboard-extract.utils.ts | 38 ++++++++++++++++ src/utils/history.utils.ts | 8 ++++ 21 files changed, 185 insertions(+), 81 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 4a7aa6fb04..43394020d5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -141,6 +141,21 @@ module.exports = [ message: "Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).", }, + { + // Toast copy must come from next-intl. `react/jsx-no-literals` below + // only inspects JSX children, so toasts fired from hooks and contexts + // (authContext, useLogin, useSendMoney, QRScanner) shipped English to + // every locale unnoticed. + // + // Deliberately NOT extended to `throw new Error('…')`: those messages + // are developer/Sentry breadcrumbs that the friendly-error mapper + // collapses to `errors.genericSupport` before any user sees them, so + // translating them would only fragment Sentry issue grouping. + selector: + "CallExpression[callee.object.name='toast'][callee.property.name=/^(error|success|info|warning|loading)$/] > :matches(Literal, TemplateLiteral):first-child", + message: + "Don't pass a string literal to toast.* — copy must come from next-intl. Import the right namespace with useTranslations and pass t('…'). If the value genuinely isn't copy (an id, a URL), assign it to a named const first.", + }, ], }, }, diff --git a/src/app/(mobile-ui)/receipt/page.tsx b/src/app/(mobile-ui)/receipt/page.tsx index 4c64cccea0..9c99cb1a2e 100644 --- a/src/app/(mobile-ui)/receipt/page.tsx +++ b/src/app/(mobile-ui)/receipt/page.tsx @@ -53,7 +53,7 @@ export default function NativeReceiptPage() { return (
- +
{isLoading || !entry ? ( diff --git a/src/app/receipt/[entryId]/page.tsx b/src/app/receipt/[entryId]/page.tsx index 68a4131f33..3514d97d9a 100644 --- a/src/app/receipt/[entryId]/page.tsx +++ b/src/app/receipt/[entryId]/page.tsx @@ -192,7 +192,7 @@ export default async function ReceiptPage({ return (
- +
diff --git a/src/components/Global/BackendErrorScreen/index.tsx b/src/components/Global/BackendErrorScreen/index.tsx index 1cc3b9db23..3a99e6f00e 100644 --- a/src/components/Global/BackendErrorScreen/index.tsx +++ b/src/components/Global/BackendErrorScreen/index.tsx @@ -8,14 +8,8 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' // inline peanut icon svg to ensure it works without needing to fetch external assets -const PeanutIcon = ({ className }: { className?: string }) => ( - +const PeanutIcon = ({ className, label }: { className?: string; label: string }) => ( + {/* peanut shape */} ( */ export default function BackendErrorScreen() { const t = useTranslations('global') + const tNav = useTranslations('navigation') const { logoutUser, isLoggingOut } = useAuth() useEffect(() => { @@ -91,7 +86,7 @@ export default function BackendErrorScreen() { return (
- +

{t('backendErrorScreen.title')}

diff --git a/src/components/Global/Banner/index.tsx b/src/components/Global/Banner/index.tsx index dcab401801..282ff7ca2f 100644 --- a/src/components/Global/Banner/index.tsx +++ b/src/components/Global/Banner/index.tsx @@ -81,7 +81,7 @@ function FeedbackBanner() { {t('betaBanner')} - Thumbs up + {t('betaBannerThumbsUpAlt')} {!IS_PRODUCTION && version: {GIT_COMMIT_HASH}} {mode && ( // High-contrast yellow-on-black pill. Visually impossible diff --git a/src/components/Global/EasterEggModal/index.tsx b/src/components/Global/EasterEggModal/index.tsx index 7bec5f4375..1600b08db3 100644 --- a/src/components/Global/EasterEggModal/index.tsx +++ b/src/components/Global/EasterEggModal/index.tsx @@ -55,7 +55,7 @@ const EasterEggModal = ({ visible, onClose, countryCode }: EasterEggModalProps) icon={ Easter egg void disableBackBtn?: boolean title?: string + /** Localized title for callers that cannot call useTranslations — i.e. server + * components, since this app has no server-side next-intl setup (locale is + * resolved entirely client-side by AppIntlProvider). Resolved against the + * `navigation` namespace. Prefer plain `title` from client components. */ + titleKey?: keyof typeof enMessages.navigation href?: string hideLabel?: boolean icon?: IconName @@ -18,6 +26,7 @@ interface NavHeaderProps { const NavHeader = ({ title, + titleKey, icon = 'chevron-up', href, hideLabel = false, @@ -27,6 +36,8 @@ const NavHeader = ({ titleClassName, }: NavHeaderProps) => { const { logoutUser, isLoggingOut } = useAuth() + const tNav = useTranslations('navigation') + const label = title ?? (titleKey ? tNav(titleKey) : undefined) return (
@@ -62,7 +73,7 @@ const NavHeader = ({ titleClassName )} > - {title} + {label}
)} diff --git a/src/components/Global/OfflineScreen/index.tsx b/src/components/Global/OfflineScreen/index.tsx index 2f42aec40f..2ed8a67d88 100644 --- a/src/components/Global/OfflineScreen/index.tsx +++ b/src/components/Global/OfflineScreen/index.tsx @@ -6,14 +6,8 @@ import { useTranslations } from 'next-intl' import { useState } from 'react' // inline peanut icon svg to ensure it works offline without needing to fetch external assets -const PeanutIcon = ({ className }: { className?: string }) => ( - +const PeanutIcon = ({ className, label }: { className?: string; label: string }) => ( + {/* */} ( */ export default function OfflineScreen() { const t = useTranslations('global') + const tNav = useTranslations('navigation') const [isChecking, setIsChecking] = useState(false) // verify with a real fetch instead of trusting navigator.onLine @@ -145,7 +140,7 @@ export default function OfflineScreen() { return (
- +

{t('offlineScreen.title')}

diff --git a/src/components/Global/QRScanner/index.tsx b/src/components/Global/QRScanner/index.tsx index 7904f41d67..0a5da03486 100644 --- a/src/components/Global/QRScanner/index.tsx +++ b/src/components/Global/QRScanner/index.tsx @@ -12,7 +12,7 @@ import { useToast } from '@/components/0_Bruddle/Toast' import CameraPermissionModal from './CameraPermissionModal' import { Clipboard } from '@capacitor/clipboard' import { clipboardHasStrings } from '@/utils/clipboard-detect' -import { extractPaymentValue } from '@/utils/clipboard-extract.utils' +import { extractPaymentValue, readClipboard } from '@/utils/clipboard-extract.utils' import { isAndroidNative } from '@/utils/capacitor' import { printableAddress } from '@/utils/general.utils' @@ -20,11 +20,14 @@ import { printableAddress } from '@/utils/general.utils' // Configuration // ============================================================================ +// Brand names stay verbatim — they are proper nouns in every locale. The EVM +// row is the odd one out: its label and alt text are descriptive prose, so it +// carries a key instead and is resolved at render. const PAYMENT_METHODS = [ { src: PEANUTMAN, alt: 'Peanut', name: 'Peanut' }, { src: MERCADO_PAGO, alt: 'Mercado Pago', name: 'Mercado Pago' }, { src: PIX, alt: 'PIX', name: 'PIX' }, - { src: ETHEREUM_ICON, alt: 'Ethereum and EVMs', name: 'ETH & EVMs' }, + { src: ETHEREUM_ICON, alt: null, name: null }, ] as const const CORNER_POSITIONS = [ @@ -124,7 +127,12 @@ function ScanRegionOverlay({
{PAYMENT_METHODS.map((method) => ( - + ))}