diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index a2d339a04..c5d17b247 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -9,57 +9,52 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as LoginRouteImport } from './routes/login' -import { Route as AppRouteImport } from './routes/_app' import { Route as IndexRouteImport } from './routes/index' -import { Route as MoneriumCallbackRouteImport } from './routes/monerium.callback' -import { Route as InviteTokenRouteImport } from './routes/invite.$token' -import { Route as AppTransferRouteImport } from './routes/_app/transfer' -import { Route as AppTransactionsRouteImport } from './routes/_app/transactions' -import { Route as AppSettingsRouteImport } from './routes/_app/settings' -import { Route as AppRecipientsRouteImport } from './routes/_app/recipients' -import { Route as AppQuoteRouteImport } from './routes/_app/quote' -import { Route as AppOverviewRouteImport } from './routes/_app/overview' -import { Route as AppLimitsRouteImport } from './routes/_app/limits' +import { Route as AppRouteImport } from './routes/_app' +import { Route as LoginRouteImport } from './routes/login' import { Route as AppApiKeysRouteImport } from './routes/_app/api-keys' +import { Route as AppLimitsRouteImport } from './routes/_app/limits' +import { Route as AppOverviewRouteImport } from './routes/_app/overview' +import { Route as AppQuoteRouteImport } from './routes/_app/quote' +import { Route as AppRecipientsRouteImport } from './routes/_app/recipients' +import { Route as AppSettingsRouteImport } from './routes/_app/settings' +import { Route as AppTransactionsRouteImport } from './routes/_app/transactions' +import { Route as AppTransferRouteImport } from './routes/_app/transfer' +import { Route as InviteTokenRouteImport } from './routes/invite.$token' +import { Route as MoneriumCallbackRouteImport } from './routes/monerium.callback' -const LoginRoute = LoginRouteImport.update({ - id: '/login', - path: '/login', - getParentRoute: () => rootRouteImport, -} as any) -const AppRoute = AppRouteImport.update({ - id: '/_app', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const MoneriumCallbackRoute = MoneriumCallbackRouteImport.update({ - id: '/monerium/callback', - path: '/monerium/callback', +const AppRoute = AppRouteImport.update({ + id: '/_app', getParentRoute: () => rootRouteImport, } as any) -const InviteTokenRoute = InviteTokenRouteImport.update({ - id: '/invite/$token', - path: '/invite/$token', +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', getParentRoute: () => rootRouteImport, } as any) -const AppTransferRoute = AppTransferRouteImport.update({ - id: '/transfer', - path: '/transfer', +const AppApiKeysRoute = AppApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', getParentRoute: () => AppRoute, } as any) -const AppTransactionsRoute = AppTransactionsRouteImport.update({ - id: '/transactions', - path: '/transactions', +const AppLimitsRoute = AppLimitsRouteImport.update({ + id: '/limits', + path: '/limits', getParentRoute: () => AppRoute, } as any) -const AppSettingsRoute = AppSettingsRouteImport.update({ - id: '/settings', - path: '/settings', +const AppOverviewRoute = AppOverviewRouteImport.update({ + id: '/overview', + path: '/overview', + getParentRoute: () => AppRoute, +} as any) +const AppQuoteRoute = AppQuoteRouteImport.update({ + id: '/quote', + path: '/quote', getParentRoute: () => AppRoute, } as any) const AppRecipientsRoute = AppRecipientsRouteImport.update({ @@ -67,25 +62,30 @@ const AppRecipientsRoute = AppRecipientsRouteImport.update({ path: '/recipients', getParentRoute: () => AppRoute, } as any) -const AppQuoteRoute = AppQuoteRouteImport.update({ - id: '/quote', - path: '/quote', +const AppSettingsRoute = AppSettingsRouteImport.update({ + id: '/settings', + path: '/settings', getParentRoute: () => AppRoute, } as any) -const AppOverviewRoute = AppOverviewRouteImport.update({ - id: '/overview', - path: '/overview', +const AppTransactionsRoute = AppTransactionsRouteImport.update({ + id: '/transactions', + path: '/transactions', getParentRoute: () => AppRoute, } as any) -const AppLimitsRoute = AppLimitsRouteImport.update({ - id: '/limits', - path: '/limits', +const AppTransferRoute = AppTransferRouteImport.update({ + id: '/transfer', + path: '/transfer', getParentRoute: () => AppRoute, } as any) -const AppApiKeysRoute = AppApiKeysRouteImport.update({ - id: '/api-keys', - path: '/api-keys', - getParentRoute: () => AppRoute, +const InviteTokenRoute = InviteTokenRouteImport.update({ + id: '/invite/$token', + path: '/invite/$token', + getParentRoute: () => rootRouteImport, +} as any) +const MoneriumCallbackRoute = MoneriumCallbackRouteImport.update({ + id: '/monerium/callback', + path: '/monerium/callback', + getParentRoute: () => rootRouteImport, } as any) export interface FileRoutesByFullPath { @@ -188,11 +188,11 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/login': { - id: '/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LoginRouteImport + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } '/_app': { @@ -202,46 +202,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteImport parentRoute: typeof rootRouteImport } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - '/monerium/callback': { - id: '/monerium/callback' - path: '/monerium/callback' - fullPath: '/monerium/callback' - preLoaderRoute: typeof MoneriumCallbackRouteImport + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } - '/invite/$token': { - id: '/invite/$token' - path: '/invite/$token' - fullPath: '/invite/$token' - preLoaderRoute: typeof InviteTokenRouteImport - parentRoute: typeof rootRouteImport + '/_app/api-keys': { + id: '/_app/api-keys' + path: '/api-keys' + fullPath: '/api-keys' + preLoaderRoute: typeof AppApiKeysRouteImport + parentRoute: typeof AppRoute } - '/_app/transfer': { - id: '/_app/transfer' - path: '/transfer' - fullPath: '/transfer' - preLoaderRoute: typeof AppTransferRouteImport + '/_app/limits': { + id: '/_app/limits' + path: '/limits' + fullPath: '/limits' + preLoaderRoute: typeof AppLimitsRouteImport parentRoute: typeof AppRoute } - '/_app/transactions': { - id: '/_app/transactions' - path: '/transactions' - fullPath: '/transactions' - preLoaderRoute: typeof AppTransactionsRouteImport + '/_app/overview': { + id: '/_app/overview' + path: '/overview' + fullPath: '/overview' + preLoaderRoute: typeof AppOverviewRouteImport parentRoute: typeof AppRoute } - '/_app/settings': { - id: '/_app/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof AppSettingsRouteImport + '/_app/quote': { + id: '/_app/quote' + path: '/quote' + fullPath: '/quote' + preLoaderRoute: typeof AppQuoteRouteImport parentRoute: typeof AppRoute } '/_app/recipients': { @@ -251,33 +244,40 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRecipientsRouteImport parentRoute: typeof AppRoute } - '/_app/quote': { - id: '/_app/quote' - path: '/quote' - fullPath: '/quote' - preLoaderRoute: typeof AppQuoteRouteImport + '/_app/settings': { + id: '/_app/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AppSettingsRouteImport parentRoute: typeof AppRoute } - '/_app/overview': { - id: '/_app/overview' - path: '/overview' - fullPath: '/overview' - preLoaderRoute: typeof AppOverviewRouteImport + '/_app/transactions': { + id: '/_app/transactions' + path: '/transactions' + fullPath: '/transactions' + preLoaderRoute: typeof AppTransactionsRouteImport parentRoute: typeof AppRoute } - '/_app/limits': { - id: '/_app/limits' - path: '/limits' - fullPath: '/limits' - preLoaderRoute: typeof AppLimitsRouteImport + '/_app/transfer': { + id: '/_app/transfer' + path: '/transfer' + fullPath: '/transfer' + preLoaderRoute: typeof AppTransferRouteImport parentRoute: typeof AppRoute } - '/_app/api-keys': { - id: '/_app/api-keys' - path: '/api-keys' - fullPath: '/api-keys' - preLoaderRoute: typeof AppApiKeysRouteImport - parentRoute: typeof AppRoute + '/invite/$token': { + id: '/invite/$token' + path: '/invite/$token' + fullPath: '/invite/$token' + preLoaderRoute: typeof InviteTokenRouteImport + parentRoute: typeof rootRouteImport + } + '/monerium/callback': { + id: '/monerium/callback' + path: '/monerium/callback' + fullPath: '/monerium/callback' + preLoaderRoute: typeof MoneriumCallbackRouteImport + parentRoute: typeof rootRouteImport } } } diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 82c572ef6..4266a391b 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -10,6 +10,8 @@ frontend-scoped architecture, conventions, and commands. Run commands from - **Forms**: React Hook Form with Zod validation (not Yup). - **Data fetching**: TanStack Query. - **Routing**: TanStack Router — route tree auto-generated in `src/routeTree.gen.ts` (do not hand-edit). +- **Rendering**: TanStack Start. Marketing routes are prerendered to static HTML at build time + (`dist/client`); `/widget` sets `ssr: false` and is served from the `_shell.html` SPA shell. - **State machines**: XState machines in `src/machines/` for complex flows (KYC, ramp process). - **Wallets**: Wagmi/AppKit (EVM) + Talisman (Polkadot). @@ -21,6 +23,17 @@ frontend-scoped architecture, conventions, and commands. Run commands from - Extract complex conditional rendering into new components. - Skip useless comments; only comment race conditions, TODOs, or genuinely confusing code. +### Server-safe code (marketing routes) + +Anything rendered by a marketing route is prerendered in a DOM-less environment, so: + +- `useSyncExternalStore` must be given a server snapshot (third argument). +- Probe browser globals with `typeof` — `localStorage` is *undeclared* on the server, so + `!localStorage` and `localStorage?.x` both throw a `ReferenceError`. +- Size from CSS, not from a measured viewport, or the layout shifts on hydration. + +`src/tests/ssr-safety.test.tsx` guards these; keep it in the default `node` environment. + ### XState v5 - Use `setup({ ... }).createMachine(...)` — not `createMachine` directly. diff --git a/apps/frontend/_redirects b/apps/frontend/_redirects index 9f42a4afb..f5065418d 100644 --- a/apps/frontend/_redirects +++ b/apps/frontend/_redirects @@ -2,21 +2,16 @@ /api/staging/* https://api-staging.vortexfinance.co/:splat 200 /api/sandbox/* https://api-sandbox.vortexfinance.co/:splat 200 -# Known app routes — serve SPA shell with real 200 -/ /index.html 200 -/business /index.html 200 -/payments /index.html 200 -/contact /index.html 200 -/privacy-policy /index.html 200 -/terms-and-conditions /index.html 200 -/terms-and-conditions-full /index.html 200 -/widget /index.html 200 -/en /index.html 200 -/en/* /index.html 200 -/pt-BR /index.html 200 -/pt-BR/* /index.html 200 -/pt-br /index.html 200 -/pt-br/* /index.html 200 +# The marketing routes are prerendered to static HTML (`/business/index.html`, `/pt-BR/...`), +# and Netlify serves those files before applying any non-forced rule below. + +# The widget opts out of SSR, so it renders from the prerendered SPA shell. +/widget /_shell.html 200 +/en/widget /_shell.html 200 +/pt-BR/widget /_shell.html 200 + +# Support the lowercase widget alias without turning unknown localized paths into soft 404s. +/pt-br/widget /_shell.html 200 # Everything else is a real 404 /* /404.html 404 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/netlify.toml b/apps/frontend/netlify.toml index 832aec9dc..9f3a5b148 100644 --- a/apps/frontend/netlify.toml +++ b/apps/frontend/netlify.toml @@ -4,10 +4,12 @@ # CAUTION: once this file is on the deployed branch it overrides the build settings # configured in the Netlify UI — review it against the current UI configuration. # -# Redirect rules live in `_redirects` (copied into dist by the build), not here. +# Redirect rules live in `_redirects` (copied into dist/client by the build), not here. +# Marketing routes are prerendered to static HTML by TanStack Start, so they are plain files +# under `dist/client`; only the SSR-disabled widget route needs a rewrite. # The dashboard is a separate Netlify site (apps/dashboard/netlify.toml) served on its # own domain; this build does not include it. [build] command = "cd ../.. && bun install --frozen-lockfile && bun run build:shared && bun run build:sdk && bun run build:frontend" - publish = "dist" + publish = "dist/client" 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.test.tsx b/apps/frontend/src/client.test.tsx new file mode 100644 index 000000000..81ad73c34 --- /dev/null +++ b/apps/frontend/src/client.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + addIntegration: vi.fn(), + captureException: vi.fn(), + getUserId: vi.fn(), + hydrateRoot: vi.fn(), + hydrateStart: vi.fn(), + initializeEvmTokens: vi.fn(), + init: vi.fn(), + replayIntegration: vi.fn(() => ({ name: "Replay" })), + routerTracingIntegration: vi.fn(() => ({ name: "TanStackRouterTracing" })) +})); + +vi.mock("@sentry/react", () => ({ + addIntegration: mocks.addIntegration, + captureException: mocks.captureException, + init: mocks.init, + reactErrorHandler: vi.fn(() => vi.fn()), + replayIntegration: mocks.replayIntegration, + setUser: vi.fn(), + tanstackRouterBrowserTracingIntegration: mocks.routerTracingIntegration +})); +vi.mock("@tanstack/react-router", () => ({ RouterProvider: vi.fn() })); +vi.mock("@tanstack/react-start/client", () => ({ hydrateStart: mocks.hydrateStart })); +vi.mock("react-dom/client", () => ({ hydrateRoot: mocks.hydrateRoot })); +vi.mock("./config", () => ({ config: { env: "test", isProd: false } })); +vi.mock("./helpers/googleTranslate", () => ({})); +vi.mock("./helpers/sentry", () => ({ + SENTRY_DENY_URLS: [], + SENTRY_IGNORE_ERRORS: [], + sentryBeforeSend: vi.fn() +})); +vi.mock("./services/auth", () => ({ AuthService: { getUserId: mocks.getUserId } })); +vi.mock("./services/tokens", () => ({ initializeEvmTokens: mocks.initializeEvmTokens })); + +describe("client hydration monitoring", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv("VITE_SENTRY_DSN", "https://public@example.invalid/1"); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it("initializes monitoring before router hydration and reports startup failures", async () => { + const hydrationError = new Error("router hydration failed"); + mocks.hydrateStart.mockRejectedValueOnce(hydrationError); + + await import("./client"); + await vi.waitFor(() => expect(mocks.captureException).toHaveBeenCalledWith(hydrationError)); + + expect(mocks.init.mock.invocationCallOrder[0]).toBeLessThan(mocks.hydrateStart.mock.invocationCallOrder[0]); + expect(mocks.hydrateRoot).not.toHaveBeenCalled(); + }); + + it("adds router tracing when hydration produces the router", async () => { + const router = { id: "router" }; + const tracingIntegration = { name: "TanStackRouterTracing" }; + mocks.hydrateStart.mockResolvedValueOnce(router); + mocks.routerTracingIntegration.mockReturnValueOnce(tracingIntegration); + + await import("./client"); + await vi.waitFor(() => expect(mocks.hydrateRoot).toHaveBeenCalled()); + + expect(mocks.routerTracingIntegration).toHaveBeenCalledWith(router); + expect(mocks.addIntegration).toHaveBeenCalledWith(tracingIntegration); + }); +}); diff --git a/apps/frontend/src/client.tsx b/apps/frontend/src/client.tsx new file mode 100644 index 000000000..481968906 --- /dev/null +++ b/apps/frontend/src/client.tsx @@ -0,0 +1,74 @@ +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 router hydration so startup failures are reportable. The +// router tracing integration is attached once Start hands over the router instance. +function initSentry(): boolean { + const sentryDsn = import.meta.env.VITE_SENTRY_DSN; + if (!sentryDsn) { + return false; + } + + 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.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 }); + } + + return true; +} + +const sentryInitialized = initSentry(); + +// Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) +initializeEvmTokens(); + +hydrateStart() + .then(router => { + if (sentryInitialized) { + Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router)); + } + + startTransition(() => { + hydrateRoot(document, , { + onCaughtError: Sentry.reactErrorHandler(), + onRecoverableError: Sentry.reactErrorHandler(), + onUncaughtError: Sentry.reactErrorHandler() + }); + }); + }) + .catch(error => { + // Hydration never produced a router, so the app will never mount. On a prerendered marketing + // route the visitor is left with static, inert HTML; on `/widget` (ssr: false) the page stays + // blank. Report the failure through the base monitoring initialized above. + Sentry.captureException(error); + }); 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/config/supabase.ts b/apps/frontend/src/config/supabase.ts index 609691faf..087075f5f 100644 --- a/apps/frontend/src/config/supabase.ts +++ b/apps/frontend/src/config/supabase.ts @@ -1,18 +1,28 @@ import { createClient } from "@supabase/supabase-js"; -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +let supabaseClient: ReturnType | undefined; -if (!supabaseUrl || !supabaseAnonKey) { - throw new Error("Missing Supabase environment variables"); -} +export function getSupabaseClient(): ReturnType { + if (supabaseClient) { + return supabaseClient; + } + + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; + const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; -export const supabase = createClient(supabaseUrl, supabaseAnonKey, { - auth: { - // The app owns token storage and refresh (backend /auth/refresh + a single scheduler). - // Keep the client passive so it doesn't run a competing background refresh. - autoRefreshToken: false, - detectSessionInUrl: false, - persistSession: false + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing Supabase environment variables"); } -}); + + supabaseClient = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + // The app owns token storage and refresh (backend /auth/refresh + a single scheduler). + // Keep the client passive so it doesn't run a competing background refresh. + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: false + } + }); + + return supabaseClient; +} 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/useAuthTokens.ts b/apps/frontend/src/hooks/useAuthTokens.ts index 17831fe66..81cf94d1d 100644 --- a/apps/frontend/src/hooks/useAuthTokens.ts +++ b/apps/frontend/src/hooks/useAuthTokens.ts @@ -1,7 +1,7 @@ import { useSelector } from "@xstate/react"; import { useCallback, useEffect, useRef } from "react"; import type { ActorRefFrom } from "xstate"; -import { supabase } from "../config/supabase"; +import { getSupabaseClient } from "../config/supabase"; import type { rampMachine } from "../machines/ramp.machine"; import { AuthService } from "../services/auth"; @@ -20,8 +20,8 @@ export function useAuthTokens(actorRef: ActorRefFrom) { const urlTokens = AuthService.handleUrlTokens(); if (urlTokens) { // Use the URL tokens to set session with Supabase, then get full user details - supabase.auth - .setSession({ + getSupabaseClient() + .auth.setSession({ access_token: urlTokens.accessToken, refresh_token: urlTokens.refreshToken }) 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/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 ( + + + + + +