From 3edd9480f3dce71bdfa3ef2c1f9f27a8d62bf11d Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 13 Aug 2026 16:48:52 +0200 Subject: [PATCH 01/10] fix(frontend): make browser-global access server-safe --- apps/frontend/src/components/Globe/index.tsx | 21 ++++--- .../src/components/Ramp/Offramp/index.tsx | 14 ++--- .../src/components/Ramp/Onramp/index.tsx | 14 ++--- .../src/components/ToastPopover/index.tsx | 2 +- apps/frontend/src/contexts/rampState.tsx | 5 ++ apps/frontend/src/hooks/useEvmTokensLoaded.ts | 7 +++ apps/frontend/src/hooks/useRampUrlParams.ts | 9 ++- apps/frontend/src/hooks/useSyncFormToUrl.ts | 7 ++- apps/frontend/src/services/storage/local.ts | 20 ++++--- apps/frontend/src/wagmiConfig.ts | 59 +++++++++++-------- 10 files changed, 86 insertions(+), 72 deletions(-) create mode 100644 apps/frontend/src/hooks/useEvmTokensLoaded.ts diff --git a/apps/frontend/src/components/Globe/index.tsx b/apps/frontend/src/components/Globe/index.tsx index cc928475b..b85a7938d 100644 --- a/apps/frontend/src/components/Globe/index.tsx +++ b/apps/frontend/src/components/Globe/index.tsx @@ -21,6 +21,11 @@ const GLOBE_SIZES = { sm: 560 } as const; +// The rendered size comes from CSS, not from `size`, so the prerendered HTML is already correct +// at every breakpoint instead of shifting on hydration. Tailwind only sees literal class strings, +// so these must be spelled out; they mirror GLOBE_SIZES and the breakpoints in getGlobeSize. +const GLOBE_SIZE_CLASSES = "h-[560px] w-[560px] sm:h-[780px] sm:w-[780px] lg:h-[960px] lg:w-[960px]"; + const CURRENCY_MARKERS = [ { currency: "usd", icon: USD_ICON, lat: 38.91, lng: -77.04 }, { currency: "brl", icon: BRL_ICON, lat: -15.8, lng: -47.89 }, @@ -31,6 +36,8 @@ const CURRENCY_MARKERS = [ ] as const; function getGlobeSize(): number { + // No viewport during SSR/prerender; the size is corrected on hydration. + if (typeof window === "undefined") return GLOBE_SIZES.lg; if (window.matchMedia("(min-width: 1024px)").matches) return GLOBE_SIZES.lg; if (window.matchMedia("(min-width: 640px)").matches) return GLOBE_SIZES.md; return GLOBE_SIZES.sm; @@ -185,16 +192,14 @@ export const Globe = ({ className }: GlobeProps) => { return (
- + {/* cobe replaces the backing store with a devicePixelRatio-scaled buffer on mount, and + nothing is drawn before then, so these attributes only need to match between the + server and client render. */} +
{CURRENCY_MARKERS.map((m, i) => ( { const { openTokenSelectModal } = useTokenSelectionActions(); - const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot); + const evmTokensLoaded = useEvmTokensLoaded(); const tokenLoading = isNetworkEVM(selectedNetwork as Networks) && !evmTokensLoaded; const fromToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig()); diff --git a/apps/frontend/src/components/Ramp/Onramp/index.tsx b/apps/frontend/src/components/Ramp/Onramp/index.tsx index 6e51bf968..f434353a9 100644 --- a/apps/frontend/src/components/Ramp/Onramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Onramp/index.tsx @@ -1,13 +1,6 @@ -import { - getAnyFiatTokenDetails, - getEvmTokensLoadedSnapshot, - getOnChainTokenDetailsOrDefault, - isNetworkEVM, - Networks, - subscribeEvmTokensLoaded -} from "@vortexfi/shared"; +import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, isNetworkEVM, Networks } from "@vortexfi/shared"; import { motion } from "motion/react"; -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; @@ -16,6 +9,7 @@ import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; +import { useEvmTokensLoaded } from "../../../hooks/useEvmTokensLoaded"; import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; @@ -58,7 +52,7 @@ export const Onramp = () => { const { openTokenSelectModal } = useTokenSelectionActions(); - const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot); + const evmTokensLoaded = useEvmTokensLoaded(); const tokenLoading = isNetworkEVM(selectedNetwork as Networks) && !evmTokensLoaded; const fromToken = getAnyFiatTokenDetails(fiatToken); diff --git a/apps/frontend/src/components/ToastPopover/index.tsx b/apps/frontend/src/components/ToastPopover/index.tsx index e653d6237..d6051e659 100644 --- a/apps/frontend/src/components/ToastPopover/index.tsx +++ b/apps/frontend/src/components/ToastPopover/index.tsx @@ -22,7 +22,7 @@ function getSnapshot() { } export function useHasActiveToasts() { - return useSyncExternalStore(subscribe, getSnapshot) > 0; + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) > 0; } export const ToastPopover = (props: ToastContainerProps) => { diff --git a/apps/frontend/src/contexts/rampState.tsx b/apps/frontend/src/contexts/rampState.tsx index 725676a37..88b269e69 100644 --- a/apps/frontend/src/contexts/rampState.tsx +++ b/apps/frontend/src/contexts/rampState.tsx @@ -24,6 +24,11 @@ const TOKEN_REFRESH_RETRY_MS = 30 * 1000; // retry after a transient failure export { readRampEphemerals, removeRampEphemeral, updateRampEphemeral } from "../services/rampEphemerals"; function readPersistedRampState(): Snapshot | undefined { + // No persisted state to restore during SSR/prerender; the client re-reads it on hydration. + if (typeof localStorage === "undefined") { + return undefined; + } + try { const raw = localStorage.getItem(RAMP_STATE_STORAGE_KEY); if (!raw) return undefined; diff --git a/apps/frontend/src/hooks/useEvmTokensLoaded.ts b/apps/frontend/src/hooks/useEvmTokensLoaded.ts new file mode 100644 index 000000000..76f2a5b51 --- /dev/null +++ b/apps/frontend/src/hooks/useEvmTokensLoaded.ts @@ -0,0 +1,7 @@ +import { getEvmTokensLoadedSnapshot, subscribeEvmTokensLoaded } from "@vortexfi/shared"; +import { useSyncExternalStore } from "react"; + +// The snapshot is a plain boolean read, so the same getter serves as the server snapshot — +// without one, prerendering the routes that call this throws "Missing getServerSnapshot". +export const useEvmTokensLoaded = (): boolean => + useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot, getEvmTokensLoadedSnapshot); diff --git a/apps/frontend/src/hooks/useRampUrlParams.ts b/apps/frontend/src/hooks/useRampUrlParams.ts index 1df44a70e..5f26a2c55 100644 --- a/apps/frontend/src/hooks/useRampUrlParams.ts +++ b/apps/frontend/src/hooks/useRampUrlParams.ts @@ -6,7 +6,6 @@ import { EvmToken, FiatToken, getEvmTokenConfig, - getEvmTokensLoadedSnapshot, isNetworkEVM, logger, mapFiatToDestination, @@ -15,11 +14,10 @@ import { OnChainTokenSymbol, PaymentMethod, QuoteResponse, - RampDirection, - subscribeEvmTokensLoaded + RampDirection } from "@vortexfi/shared"; import Big from "big.js"; -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { isFrontendNetworkEnabled } from "../config/networkAvailability"; import { getFirstEnabledFiatToken, isFiatTokenEnabled } from "../config/tokenAvailability"; import { useNetwork } from "../contexts/network"; @@ -31,6 +29,7 @@ import { useQuoteFormStoreActions } from "../stores/quote/useQuoteFormStore"; import { useQuoteStore } from "../stores/quote/useQuoteStore"; import { useRampDirection, useRampDirectionToggle } from "../stores/rampDirectionStore"; import { RampSearchParams } from "../types/searchParams"; +import { useEvmTokensLoaded } from "./useEvmTokensLoaded"; import { useWidgetMode } from "./useWidgetMode"; interface RampUrlParams { @@ -180,7 +179,7 @@ export const useRampUrlParams = (): RampUrlParams => { const searchParams = useSearch({ strict: false }) as RampSearchParams; const { selectedNetwork } = useNetwork(); const rampDirectionStore = useRampDirection(); - const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot); + const evmTokensLoaded = useEvmTokensLoaded(); const urlParams = useMemo(() => { const rampDirectionParam = searchParams.rampType?.toUpperCase(); diff --git a/apps/frontend/src/hooks/useSyncFormToUrl.ts b/apps/frontend/src/hooks/useSyncFormToUrl.ts index 7276af1f9..d9f2a6deb 100644 --- a/apps/frontend/src/hooks/useSyncFormToUrl.ts +++ b/apps/frontend/src/hooks/useSyncFormToUrl.ts @@ -1,9 +1,10 @@ import { useNavigate } from "@tanstack/react-router"; -import { getEvmTokensLoadedSnapshot, isNetworkEVM, Networks, subscribeEvmTokensLoaded } from "@vortexfi/shared"; -import { useEffect, useSyncExternalStore } from "react"; +import { isNetworkEVM, Networks } from "@vortexfi/shared"; +import { useEffect } from "react"; import { useNetwork } from "../contexts/network"; import { useFiatToken, useInputAmount, useOnChainToken } from "../stores/quote/useQuoteFormStore"; import { useRampDirection } from "../stores/rampDirectionStore"; +import { useEvmTokensLoaded } from "./useEvmTokensLoaded"; export const useSyncFormToUrl = () => { const inputAmount = useInputAmount(); @@ -12,7 +13,7 @@ export const useSyncFormToUrl = () => { const rampDirection = useRampDirection(); const { selectedNetwork } = useNetwork(); const navigate = useNavigate(); - const evmTokensLoaded = useSyncExternalStore(subscribeEvmTokensLoaded, getEvmTokensLoadedSnapshot); + const evmTokensLoaded = useEvmTokensLoaded(); useEffect(() => { navigate({ diff --git a/apps/frontend/src/services/storage/local.ts b/apps/frontend/src/services/storage/local.ts index 67bd059af..64aff2b79 100644 --- a/apps/frontend/src/services/storage/local.ts +++ b/apps/frontend/src/services/storage/local.ts @@ -2,30 +2,32 @@ import { Storage } from "./types"; const exists = (value?: string | null): value is string => !!value && value.length > 0; +// During SSR/prerender `localStorage` is an undeclared global, so `!localStorage` and +// `localStorage?.x` both throw a ReferenceError — it has to be probed with `typeof`. +const browserStorage: globalThis.Storage | undefined = typeof localStorage === "undefined" ? undefined : localStorage; + export const storageService: Storage = { get: (key, defaultValue?) => { - if (!localStorage) return defaultValue; - const value = localStorage.getItem(key); + const value = browserStorage?.getItem(key); return exists(value) ? value : defaultValue; }, - getBoolean: (key: string) => Boolean(localStorage?.getItem(key)), + getBoolean: (key: string) => Boolean(browserStorage?.getItem(key)), - getNumber: (key: string) => Number(localStorage?.getItem(key)), + getNumber: (key: string) => Number(browserStorage?.getItem(key)), getParsed: (key, defaultValue?, parser = JSON.parse) => { - if (!localStorage) return defaultValue; - const value = localStorage.getItem(key); + const value = browserStorage?.getItem(key); if (!exists(value)) return defaultValue; try { - return parser(value as string); + return parser(value); } catch (_e) { return defaultValue; } }, - remove: key => localStorage?.removeItem(key), + remove: key => browserStorage?.removeItem(key), set: (key, value?) => - localStorage?.setItem( + browserStorage?.setItem( key, (value && typeof value === "object") || Array.isArray(value) ? JSON.stringify(value) : String(value) ) diff --git a/apps/frontend/src/wagmiConfig.ts b/apps/frontend/src/wagmiConfig.ts index 1c227c4e6..d9e15c1f3 100644 --- a/apps/frontend/src/wagmiConfig.ts +++ b/apps/frontend/src/wagmiConfig.ts @@ -43,31 +43,38 @@ const wagmiAdapter = new WagmiAdapter({ transports }); -createAppKit({ - adapters: [wagmiAdapter], - enableEIP6963: true, - enableWalletGuide: false, - // Some wallets are not always shown. We can define them with their ID found [here](https://walletguide.walletconnect.network/) - featuredWalletIds: [ - "c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96", // metamask - "a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393", // phantom - "18388be9ac2d02726dbac9777c96efaac06d744b2f6d580fccdd4127a6d01fd1" // rabby - ], - features: { - analytics: false, - email: false, - onramp: false, - socials: false, - swaps: false - }, - metadata, - // @ts-expect-error - networks is not typed - networks, - projectId, - themeMode: "light", - themeVariables: { - "--w3m-accent": "oklch(0.26 0.07 260)" - } -}); +// AppKit registers custom elements and a browser-global modal singleton, so it must not run +// during prerender: `__root.tsx` imports this module for `wagmiConfig`, which would otherwise +// drag the whole modal into the server bundle for every marketing page. Every consumer of the +// AppKit hooks (EVMWalletButton, SwapSubmitButton, QuoteSubmitButtons) lives under the widget +// route, which is `ssr: false`, so they only ever render after this has run in the browser. +if (typeof window !== "undefined") { + createAppKit({ + adapters: [wagmiAdapter], + enableEIP6963: true, + enableWalletGuide: false, + // Some wallets are not always shown. We can define them with their ID found [here](https://walletguide.walletconnect.network/) + featuredWalletIds: [ + "c57ca95b47569778a828d19178114f4db188b89b763c899ba0be274e97267d96", // metamask + "a797aa35c0fadbfc1a53e7f675162ed5226968b44a19ee3d24385c64d1d3c393", // phantom + "18388be9ac2d02726dbac9777c96efaac06d744b2f6d580fccdd4127a6d01fd1" // rabby + ], + features: { + analytics: false, + email: false, + onramp: false, + socials: false, + swaps: false + }, + metadata, + // @ts-expect-error - networks is not typed + networks, + projectId, + themeMode: "light", + themeVariables: { + "--w3m-accent": "oklch(0.26 0.07 260)" + } + }); +} export const wagmiConfig = wagmiAdapter.wagmiConfig; From 294889bb9dd19109f655ca7c8a89226b7bcfc28c Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 13 Aug 2026 16:52:41 +0200 Subject: [PATCH 02/10] feat(frontend): render marketing routes with tanstack start --- apps/frontend/index.html | 51 ----------- apps/frontend/package.json | 15 ++-- apps/frontend/src/client.tsx | 64 ++++++++++++++ apps/frontend/src/i18n.ts | 26 ++++++ apps/frontend/src/main.tsx | 118 ------------------------- apps/frontend/src/routeTree.gen.ts | 129 +++++++++++++++------------- apps/frontend/src/router.tsx | 21 +++++ apps/frontend/src/routes/__root.tsx | 123 +++++++++++++++++++++++--- apps/frontend/vite.config.ts | 66 ++++++++++++-- bun.lock | 81 ++++++++++++----- 10 files changed, 414 insertions(+), 280 deletions(-) delete mode 100644 apps/frontend/index.html create mode 100644 apps/frontend/src/client.tsx create mode 100644 apps/frontend/src/i18n.ts delete mode 100644 apps/frontend/src/main.tsx create mode 100644 apps/frontend/src/router.tsx diff --git a/apps/frontend/index.html b/apps/frontend/index.html deleted file mode 100644 index 0ab58518c..000000000 --- a/apps/frontend/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - Vortex - - - - - - - - - - - - - - - -
- - - diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2a05b5a3a..61a8b0b17 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -29,12 +29,13 @@ "@talismn/connect-components": "^1.1.9", "@talismn/connect-wallets": "^1.2.8", "@tanstack/react-query": "^5.64.2", - "@tanstack/react-router": "^1.136.8", - "@tanstack/react-router-devtools": "^1.136.8", + "@tanstack/react-router": "^1.170.25", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-start": "^1.168.42", "@tanstack/react-virtual": "^3.13.18", - "@tanstack/zod-adapter": "^1.144.0", + "@tanstack/zod-adapter": "^1.167.0", "@types/crypto-js": "^4.2.2", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.2.0", "@vortexfi/kyc": "workspace:*", "@vortexfi/shared": "workspace:*", "@wagmi/core": "catalog:", @@ -88,7 +89,7 @@ "@polkadot/types-known": "catalog:", "@storybook/react-vite": "^9.1.4", "@tanstack/react-query-devtools": "^5.91.1", - "@tanstack/router-plugin": "^1.136.8", + "@tanstack/router-plugin": "^1.168.29", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -117,7 +118,7 @@ "ts-node": "^10.9.1", "tw-animate-css": "^1.4.0", "typescript": "catalog:", - "vite": "^6.2.6", + "vite": "^7.3.5", "vite-plugin-node-polyfills": "^0.23.0", "vitest": "^3.1.1" }, @@ -129,7 +130,7 @@ "name": "vortex-frontend", "private": true, "scripts": { - "build": "bun x --bun vite build && cp -R src/assets/coins dist/assets/coins && cp _redirects dist/_redirects", + "build": "bun x --bun vite build && cp -R src/assets/coins dist/client/assets/coins && cp _redirects dist/client/_redirects", "build-storybook": "storybook build", "dev": "rm -rf node_modules/.vite && bun x --bun vite --host", "preview": "bun x --bun vite preview", diff --git a/apps/frontend/src/client.tsx b/apps/frontend/src/client.tsx new file mode 100644 index 000000000..33b7b5b02 --- /dev/null +++ b/apps/frontend/src/client.tsx @@ -0,0 +1,64 @@ +import * as Sentry from "@sentry/react"; +import { RouterProvider } from "@tanstack/react-router"; +import { hydrateStart } from "@tanstack/react-start/client"; +import { startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { config } from "./config"; +import { SENTRY_DENY_URLS, SENTRY_IGNORE_ERRORS, sentryBeforeSend } from "./helpers/sentry"; +import { AuthService } from "./services/auth"; +import { initializeEvmTokens } from "./services/tokens"; +import "./helpers/googleTranslate"; + +// Sentry must initialize before the app renders. The TanStack Router tracing integration +// needs the router instance, which Start only hands over once hydration resolves. +function initSentry(router: Parameters[0]) { + const sentryDsn = import.meta.env.VITE_SENTRY_DSN; + if (!sentryDsn) { + return; + } + + Sentry.init({ + beforeSend: sentryBeforeSend, + denyUrls: SENTRY_DENY_URLS, + dsn: sentryDsn, + enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally + environment: config.env, // production | staging | development — keeps preview/QA noise out of prod + ignoreErrors: SENTRY_IGNORE_ERRORS, + // Explicit replay masking — these are the defaults, but pinned for a KYC/KYB app so a future + // default change can't start leaking user input into replays. + integrations: [ + Sentry.tanstackRouterBrowserTracingIntegration(router), + Sentry.replayIntegration({ blockAllMedia: true, maskAllText: true }) + ], + // Capture 100% of sessions where an error occurs; sample plain sessions only in prod. + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: config.isProd ? 0.1 : 1.0, + // Only propagate trace headers to our own (same-origin) API. The API is served same-origin + // (/api/...), so this works across all Netlify branch URLs and avoids leaking headers to + // third parties (Squid, RPCs). + tracePropagationTargets: [window.location.origin], + tracesSampleRate: config.isProd ? 0.2 : 1.0 + }); + + // On a page reload the session is restored from localStorage without calling storeTokens, so + // seed the Sentry user here too (pseudonymous id only). Runtime login/logout keeps it in sync. + const restoredUserId = AuthService.getUserId(); + if (restoredUserId) { + Sentry.setUser({ id: restoredUserId }); + } +} + +// Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) +initializeEvmTokens(); + +hydrateStart().then(router => { + initSentry(router); + + startTransition(() => { + hydrateRoot(document, , { + onCaughtError: Sentry.reactErrorHandler(), + onRecoverableError: Sentry.reactErrorHandler(), + onUncaughtError: Sentry.reactErrorHandler() + }); + }); +}); diff --git a/apps/frontend/src/i18n.ts b/apps/frontend/src/i18n.ts new file mode 100644 index 000000000..5685648df --- /dev/null +++ b/apps/frontend/src/i18n.ts @@ -0,0 +1,26 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import enTranslations from "./translations/en.json"; +import { getBrowserLanguage, Language } from "./translations/helpers"; +import ptTranslations from "./translations/pt.json"; + +// Initialize i18n with browser language as default (falls back to English during SSR, +// where there is no navigator). The actual language is set by the locale route's beforeLoad. +// +// This is a module singleton whose language the `{-$locale}` route switches on every +// navigation. Prerendering therefore runs with `concurrency: 1` (see vite.config.ts) so that +// pages cannot race over the active language — do not make either side concurrent alone. +i18n.use(initReactI18next).init({ + fallbackLng: "en", + lng: getBrowserLanguage(), + resources: { + [Language.English]: { + translation: enTranslations + }, + [Language.Portuguese_Brazil]: { + translation: ptTranslations + } + } +}); + +export default i18n; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx deleted file mode 100644 index b1833fd99..000000000 --- a/apps/frontend/src/main.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import "@fontsource/roboto/300.css"; -import "@fontsource/roboto/400.css"; -import "@fontsource/roboto/500.css"; -import "@fontsource/roboto/700.css"; -import "react-toastify/dist/ReactToastify.css"; -import "../App.css"; - -import * as Sentry from "@sentry/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { createRouter, RouterProvider } from "@tanstack/react-router"; -import i18n from "i18next"; -import { createRoot } from "react-dom/client"; -import { initReactI18next } from "react-i18next"; -import { WagmiProvider } from "wagmi"; -import { config } from "./config"; -import { PolkadotNodeProvider } from "./contexts/polkadotNode"; -import { PolkadotWalletStateProvider } from "./contexts/polkadotWallet"; -import { SENTRY_DENY_URLS, SENTRY_IGNORE_ERRORS, sentryBeforeSend } from "./helpers/sentry"; -import { AuthService } from "./services/auth"; -import { initializeEvmTokens } from "./services/tokens"; -import { wagmiConfig } from "./wagmiConfig"; -import "./helpers/googleTranslate"; -import { PersistentRampStateProvider } from "./contexts/rampState"; -import { routeTree } from "./routeTree.gen"; -import enTranslations from "./translations/en.json"; -import { getBrowserLanguage, Language } from "./translations/helpers"; -import ptTranslations from "./translations/pt.json"; - -const queryClient = new QueryClient(); - -// Initialize i18n with browser language as default -// The actual language will be set by the route's beforeLoad -const lng = getBrowserLanguage(); - -i18n.use(initReactI18next).init({ - fallbackLng: "en", - lng, - resources: { - [Language.English]: { - translation: enTranslations - }, - [Language.Portuguese_Brazil]: { - translation: ptTranslations - } - } -}); - -const router = createRouter({ routeTree }); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} - -// Sentry must initialize before the app renders. The TanStack Router tracing -// integration needs the router instance, so init runs after the router is created. -const sentryDsn = import.meta.env.VITE_SENTRY_DSN; -if (sentryDsn) { - Sentry.init({ - beforeSend: sentryBeforeSend, - denyUrls: SENTRY_DENY_URLS, - dsn: sentryDsn, - enabled: !window.location.hostname.includes("localhost"), // Disable sentry entirely when testing locally - environment: config.env, // production | staging | development — keeps preview/QA noise out of prod - ignoreErrors: SENTRY_IGNORE_ERRORS, - // Explicit replay masking — these are the defaults, but pinned for a KYC/KYB app so a future - // default change can't start leaking user input into replays. - integrations: [ - Sentry.tanstackRouterBrowserTracingIntegration(router), - Sentry.replayIntegration({ blockAllMedia: true, maskAllText: true }) - ], - // Capture 100% of sessions where an error occurs; sample plain sessions only in prod. - replaysOnErrorSampleRate: 1.0, - replaysSessionSampleRate: config.isProd ? 0.1 : 1.0, - // Only propagate trace headers to our own (same-origin) API. The API is served same-origin - // (/api/...), so this works across all Netlify branch URLs and avoids leaking headers to - // third parties (Squid, RPCs). - tracePropagationTargets: [window.location.origin], - tracesSampleRate: config.isProd ? 0.2 : 1.0 - }); - - // On a page reload the session is restored from localStorage without calling storeTokens, so - // seed the Sentry user here too (pseudonymous id only). Runtime login/logout keeps it in sync. - const restoredUserId = AuthService.getUserId(); - if (restoredUserId) { - Sentry.setUser({ id: restoredUserId }); - } -} - -const root = document.getElementById("app"); - -if (!root) { - throw new Error("Root element not found"); -} - -// Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) -initializeEvmTokens(); - -createRoot(root, { - onCaughtError: Sentry.reactErrorHandler(), - onRecoverableError: Sentry.reactErrorHandler(), - onUncaughtError: Sentry.reactErrorHandler() -}).render( - - - - - - - - - - - - -); diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 5c94dcb58..40a491f20 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -11,13 +11,13 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as Char123LocaleChar125RouteImport } from './routes/{-$locale}' import { Route as Char123LocaleChar125IndexRouteImport } from './routes/{-$locale}/index' -import { Route as Char123LocaleChar125WidgetRouteImport } from './routes/{-$locale}/widget' -import { Route as Char123LocaleChar125TermsAndConditionsFullRouteImport } from './routes/{-$locale}/terms-and-conditions-full' -import { Route as Char123LocaleChar125TermsAndConditionsRouteImport } from './routes/{-$locale}/terms-and-conditions' -import { Route as Char123LocaleChar125PrivacyPolicyRouteImport } from './routes/{-$locale}/privacy-policy' -import { Route as Char123LocaleChar125PaymentsRouteImport } from './routes/{-$locale}/payments' -import { Route as Char123LocaleChar125ContactRouteImport } from './routes/{-$locale}/contact' import { Route as Char123LocaleChar125BusinessRouteImport } from './routes/{-$locale}/business' +import { Route as Char123LocaleChar125ContactRouteImport } from './routes/{-$locale}/contact' +import { Route as Char123LocaleChar125PaymentsRouteImport } from './routes/{-$locale}/payments' +import { Route as Char123LocaleChar125PrivacyPolicyRouteImport } from './routes/{-$locale}/privacy-policy' +import { Route as Char123LocaleChar125TermsAndConditionsRouteImport } from './routes/{-$locale}/terms-and-conditions' +import { Route as Char123LocaleChar125TermsAndConditionsFullRouteImport } from './routes/{-$locale}/terms-and-conditions-full' +import { Route as Char123LocaleChar125WidgetRouteImport } from './routes/{-$locale}/widget' const Char123LocaleChar125Route = Char123LocaleChar125RouteImport.update({ id: '/{-$locale}', @@ -30,22 +30,22 @@ const Char123LocaleChar125IndexRoute = path: '/', getParentRoute: () => Char123LocaleChar125Route, } as any) -const Char123LocaleChar125WidgetRoute = - Char123LocaleChar125WidgetRouteImport.update({ - id: '/widget', - path: '/widget', +const Char123LocaleChar125BusinessRoute = + Char123LocaleChar125BusinessRouteImport.update({ + id: '/business', + path: '/business', getParentRoute: () => Char123LocaleChar125Route, } as any) -const Char123LocaleChar125TermsAndConditionsFullRoute = - Char123LocaleChar125TermsAndConditionsFullRouteImport.update({ - id: '/terms-and-conditions-full', - path: '/terms-and-conditions-full', +const Char123LocaleChar125ContactRoute = + Char123LocaleChar125ContactRouteImport.update({ + id: '/contact', + path: '/contact', getParentRoute: () => Char123LocaleChar125Route, } as any) -const Char123LocaleChar125TermsAndConditionsRoute = - Char123LocaleChar125TermsAndConditionsRouteImport.update({ - id: '/terms-and-conditions', - path: '/terms-and-conditions', +const Char123LocaleChar125PaymentsRoute = + Char123LocaleChar125PaymentsRouteImport.update({ + id: '/payments', + path: '/payments', getParentRoute: () => Char123LocaleChar125Route, } as any) const Char123LocaleChar125PrivacyPolicyRoute = @@ -54,22 +54,22 @@ const Char123LocaleChar125PrivacyPolicyRoute = path: '/privacy-policy', getParentRoute: () => Char123LocaleChar125Route, } as any) -const Char123LocaleChar125PaymentsRoute = - Char123LocaleChar125PaymentsRouteImport.update({ - id: '/payments', - path: '/payments', +const Char123LocaleChar125TermsAndConditionsRoute = + Char123LocaleChar125TermsAndConditionsRouteImport.update({ + id: '/terms-and-conditions', + path: '/terms-and-conditions', getParentRoute: () => Char123LocaleChar125Route, } as any) -const Char123LocaleChar125ContactRoute = - Char123LocaleChar125ContactRouteImport.update({ - id: '/contact', - path: '/contact', +const Char123LocaleChar125TermsAndConditionsFullRoute = + Char123LocaleChar125TermsAndConditionsFullRouteImport.update({ + id: '/terms-and-conditions-full', + path: '/terms-and-conditions-full', getParentRoute: () => Char123LocaleChar125Route, } as any) -const Char123LocaleChar125BusinessRoute = - Char123LocaleChar125BusinessRouteImport.update({ - id: '/business', - path: '/business', +const Char123LocaleChar125WidgetRoute = + Char123LocaleChar125WidgetRouteImport.update({ + id: '/widget', + path: '/widget', getParentRoute: () => Char123LocaleChar125Route, } as any) @@ -161,25 +161,25 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Char123LocaleChar125IndexRouteImport parentRoute: typeof Char123LocaleChar125Route } - '/{-$locale}/widget': { - id: '/{-$locale}/widget' - path: '/widget' - fullPath: '/{-$locale}/widget' - preLoaderRoute: typeof Char123LocaleChar125WidgetRouteImport + '/{-$locale}/business': { + id: '/{-$locale}/business' + path: '/business' + fullPath: '/{-$locale}/business' + preLoaderRoute: typeof Char123LocaleChar125BusinessRouteImport parentRoute: typeof Char123LocaleChar125Route } - '/{-$locale}/terms-and-conditions-full': { - id: '/{-$locale}/terms-and-conditions-full' - path: '/terms-and-conditions-full' - fullPath: '/{-$locale}/terms-and-conditions-full' - preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsFullRouteImport + '/{-$locale}/contact': { + id: '/{-$locale}/contact' + path: '/contact' + fullPath: '/{-$locale}/contact' + preLoaderRoute: typeof Char123LocaleChar125ContactRouteImport parentRoute: typeof Char123LocaleChar125Route } - '/{-$locale}/terms-and-conditions': { - id: '/{-$locale}/terms-and-conditions' - path: '/terms-and-conditions' - fullPath: '/{-$locale}/terms-and-conditions' - preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsRouteImport + '/{-$locale}/payments': { + id: '/{-$locale}/payments' + path: '/payments' + fullPath: '/{-$locale}/payments' + preLoaderRoute: typeof Char123LocaleChar125PaymentsRouteImport parentRoute: typeof Char123LocaleChar125Route } '/{-$locale}/privacy-policy': { @@ -189,25 +189,25 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Char123LocaleChar125PrivacyPolicyRouteImport parentRoute: typeof Char123LocaleChar125Route } - '/{-$locale}/payments': { - id: '/{-$locale}/payments' - path: '/payments' - fullPath: '/{-$locale}/payments' - preLoaderRoute: typeof Char123LocaleChar125PaymentsRouteImport + '/{-$locale}/terms-and-conditions': { + id: '/{-$locale}/terms-and-conditions' + path: '/terms-and-conditions' + fullPath: '/{-$locale}/terms-and-conditions' + preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsRouteImport parentRoute: typeof Char123LocaleChar125Route } - '/{-$locale}/contact': { - id: '/{-$locale}/contact' - path: '/contact' - fullPath: '/{-$locale}/contact' - preLoaderRoute: typeof Char123LocaleChar125ContactRouteImport + '/{-$locale}/terms-and-conditions-full': { + id: '/{-$locale}/terms-and-conditions-full' + path: '/terms-and-conditions-full' + fullPath: '/{-$locale}/terms-and-conditions-full' + preLoaderRoute: typeof Char123LocaleChar125TermsAndConditionsFullRouteImport parentRoute: typeof Char123LocaleChar125Route } - '/{-$locale}/business': { - id: '/{-$locale}/business' - path: '/business' - fullPath: '/{-$locale}/business' - preLoaderRoute: typeof Char123LocaleChar125BusinessRouteImport + '/{-$locale}/widget': { + id: '/{-$locale}/widget' + path: '/widget' + fullPath: '/{-$locale}/widget' + preLoaderRoute: typeof Char123LocaleChar125WidgetRouteImport parentRoute: typeof Char123LocaleChar125Route } } @@ -247,3 +247,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx new file mode 100644 index 000000000..7de9e741b --- /dev/null +++ b/apps/frontend/src/router.tsx @@ -0,0 +1,21 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; +import "./i18n"; +import { routeTree } from "./routeTree.gen"; + +// Called once per request on the server and once on the client, so the QueryClient is +// created per router instance rather than shared across renders. +export function getRouter() { + const queryClient = new QueryClient(); + + return createRouter({ + context: { queryClient }, + routeTree + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 05fe00243..107254fb1 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -1,20 +1,115 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +import "@fontsource/roboto/300.css"; +import "@fontsource/roboto/400.css"; +import "@fontsource/roboto/500.css"; +import "@fontsource/roboto/700.css"; +import "react-toastify/dist/ReactToastify.css"; +import "../../App.css"; + +import type { QueryClient } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ClientOnly, createRootRouteWithContext, HeadContent, Outlet, Scripts, useParams } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { PropsWithChildren } from "react"; +import { WagmiProvider } from "wagmi"; import { ToastPopover } from "../components/ToastPopover"; import { EventsProvider } from "../contexts/events"; import { NetworkProvider } from "../contexts/network"; +import { PolkadotNodeProvider } from "../contexts/polkadotNode"; +import { PolkadotWalletStateProvider } from "../contexts/polkadotWallet"; +import { PersistentRampStateProvider } from "../contexts/rampState"; +import { Language } from "../translations/helpers"; +import { wagmiConfig } from "../wagmiConfig"; + +const GTM_ID = "GTM-T8JZSLD8"; + +const GTM_SNIPPET = `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': +new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], +j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= +'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); +})(window,document,'script','dataLayer','${GTM_ID}');`; + +export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ + component: RootComponent, + head: () => ({ + links: [ + { href: "/favicon-32x32.png", rel: "icon", sizes: "32x32", type: "image/png" }, + { href: "/favicon-16x16.png", rel: "icon", sizes: "16x16", type: "image/png" }, + { href: "/favicon.ico", rel: "icon", type: "image/x-icon" }, + { href: "/apple-touch-icon.png", rel: "apple-touch-icon", sizes: "180x180" }, + { href: "/site.webmanifest", rel: "manifest" }, + { href: "https://fonts.googleapis.com", rel: "preconnect" }, + { crossOrigin: "anonymous", href: "https://fonts.gstatic.com", rel: "preconnect" }, + { + href: "https://fonts.googleapis.com/css2?family=Red+Hat+Display:ital,wght@0,300..900;1,300..900&display=swap", + rel: "stylesheet" + } + ], + meta: [{ charSet: "utf-8" }, { content: "width=device-width, initial-scale=1.0", name: "viewport" }, { title: "Vortex" }], + scripts: [{ children: GTM_SNIPPET }] + }), + shellComponent: RootDocument +}); + +function RootDocument({ children }: PropsWithChildren) { + // `lang` has to reflect the active locale for crawlers reading the prerendered HTML. + const { locale } = useParams({ strict: false }); -const RootComponent = () => ( - - - - -
- {/* This is where the dialogs/modals are rendered. It is placed here because it is the highest point in the app where the tailwind data-theme is available */} -
- -
-
-); + return ( + + + + + +