Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ In the deployment location the following command will run the app:
node server.js
```

## Adding a language

Add an entry to the `LANGUAGES` array in
`src/components/LanguageSelector/index.tsx`:

```typescript
{ code: "it", label: "Italiano" }
```

`code` must be a
[BCP 47 tag supported by Google Translate](https://cloud.google.com/translate/docs/languages)
(e.g. `"zh-CN"`, `"pt"`, `"ar"`). `label` should be the language's own name,
not its English translation.

## Tracking

To improve Lab, we use [Amplitude](https://amplitude.com/) and
Expand Down
15 changes: 15 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ export default defineConfig({
video: "off",
actionTimeout: 10 * 1000,
navigationTimeout: 15 * 1000,
launchOptions: {
args: [
// Block Google Translate CDN requests so they don't slow down page
// loads in test environments. The app loads the GT script on every
// page (src/app/layout.tsx), and in environments with restricted or
// slow access to Google's CDN the external requests can push page
// loads past navigationTimeout, causing flaky failures in unrelated
// tests. Mapping these hosts to 127.0.0.1 makes connections fail
// immediately ("connection refused") rather than hanging. The async
// GT script handles the failure gracefully without blocking page load.
// Language selector tests are unaffected — they only verify cookie
// behaviour and UI state, not that the page is actually translated.
"--host-rules=MAP translate.google.com 127.0.0.1, MAP translate.googleapis.com 127.0.0.1",
],
},
},

/* Run your local dev server before starting the tests */
Expand Down
12 changes: 7 additions & 5 deletions src/app/(sidebar)/account/fund/components/SwitchNetwork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { Card, Text } from "@stellar/design-system";
import { SwitchNetworkButtons } from "@/components/SwitchNetworkButtons";
import { AssetCode } from "@/components/AssetCode";
import { NetworkName } from "@/components/NetworkName";

import "../../styles.scss";

Expand All @@ -11,15 +13,15 @@ export const SwitchNetwork = () => {
<div className="Account__card">
<div className="CardText">
<Text size="lg" as="h1" weight="medium">
Fund a Futurenet or Testnet network account or contract with XLM,
USDC, and EURC
Fund a{" "}<NetworkName>Futurenet</NetworkName>{" "}or{" "}<NetworkName>Testnet</NetworkName>{" "}network account or contract with XLM,{" "}
<AssetCode>USDC</AssetCode>, and <AssetCode>EURC</AssetCode>
</Text>

<Text size="sm" as="p">
You must switch your network to Testnet or Futurenet in order to
You must switch your network to{" "}<NetworkName>Testnet</NetworkName>{" "}or{" "}<NetworkName>Futurenet</NetworkName>{" "}in order to
fund keypairs. Friendbot is a standalone service that funds your
account or contract with XLM. To fund assets such as USDC and EURC,
you’ll need to add a trustline manually before funding.
account or contract with XLM. Adding a trustline is required to
fund assets such as{" "}<AssetCode>USDC</AssetCode>{" "}and{" "}<AssetCode>EURC</AssetCode>.
</Text>
</div>

Expand Down
18 changes: 12 additions & 6 deletions src/app/(sidebar)/account/fund/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { useFriendBot } from "@/query/useFriendBot";
import { useAccountInfo } from "@/query/useAccountInfo";
import { useAddTrustline } from "@/query/useAddTrustline";
import { useSubmitHorizonTx } from "@/query/useSubmitHorizonTx";
import { NetworkName } from "@/components/NetworkName";
import { AssetCode } from "@/components/AssetCode";

import { useStore } from "@/store/useStore";
import { EURC_TESTNET_ISSUER, USDC_TESTNET_ISSUER } from "@/constants/settings";

Expand Down Expand Up @@ -201,7 +204,7 @@ export default function FundAccount() {
id: `fund-account-success-xlm`,
type: "success",
title: "XLM has been successfully funded!",
description: `10,000 XLM was funded to ${shortenStellarAddress(inputPublicKey)} on ${network.label}.`,
description: <>10,000 XLM was funded to {shortenStellarAddress(inputPublicKey)} on <NetworkName>{network.label}</NetworkName>.</>,
});

setActiveToken("");
Expand Down Expand Up @@ -245,7 +248,7 @@ export default function FundAccount() {
id: `fund-account-success-${assetCode}`,
type: "success",
title: "Trustline added",
description: `${assetCode} trustline has been successfully added to ${shortenStellarAddress(inputPublicKey)} on ${network.label}.`,
description: <>{assetCode} trustline has been successfully added to {shortenStellarAddress(inputPublicKey)} on <NetworkName>{network.label}</NetworkName>.</>,
});
}
}, [
Expand Down Expand Up @@ -306,13 +309,13 @@ export default function FundAccount() {
return (
<div className="Account">
<PageCard
heading={`Friendbot: fund a ${network.label} account or contract with XLM, USDC, and EURC`}
heading={<>Friendbot: fund a{" "}<NetworkName>{network.label}</NetworkName>{" "}account or contract with XLM,{" "}<AssetCode>USDC</AssetCode>, and{" "}<AssetCode>EURC</AssetCode></>}
>
<div className="Account__card">
<Text size="sm" as="div">
Friendbot is a standalone service that funds your testnet account or
contract with XLM. To fund assets such as USDC and EURC, you’ll need
to add a trustline manually before funding.
contract with XLM. Adding a trustline is required to fund assets
such as{" "}<AssetCode>USDC</AssetCode>{" "}and{" "}<AssetCode>EURC</AssetCode>.
</Text>

<Input
Expand Down Expand Up @@ -428,7 +431,10 @@ export default function FundAccount() {
size="md"
weight="medium"
addlClassName="Account__fundTokens__item__amount"
>{`${formatNumber(parseFloat(t.amount))} ${t.currency}`}</Text>
>
{formatNumber(parseFloat(t.amount))}{" "}
<AssetCode>{t.currency}</AssetCode>
</Text>
</div>

{t.id === "xlm" || hasTrustline ? (
Expand Down
5 changes: 4 additions & 1 deletion src/app/(sidebar)/account/saved/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { arrayItem } from "@/helpers/arrayItem";
import { getNetworkHeaders } from "@/helpers/getNetworkHeaders";
import { getNetworkById } from "@/helpers/getNetworkById";

import { NetworkName } from "@/components/NetworkName";

import { useStore } from "@/store/useStore";
import { useIsTestingNetwork } from "@/hooks/useIsTestingNetwork";
import { useFriendBot } from "@/query/useFriendBot";
Expand Down Expand Up @@ -97,7 +99,8 @@ export default function SavedKeypairs() {
<Box gap="md">
<>
{savedKeypairs.length === 0
? `There are no saved keypairs on ${network.label} network.`
? <span>There are no saved keypairs on{" "}<NetworkName>{network.label}</NetworkName>{" "}network.</span>

: savedKeypairs.map((kp) => (
<SavedKeypairItem
key={`saved-kp-${kp.timestamp}`}
Expand Down
6 changes: 4 additions & 2 deletions src/app/(sidebar)/endpoints/components/SavedEndpointsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { localStorageSavedRpcMethods } from "@/helpers/localStorageSavedRpcMetho
import { arrayItem } from "@/helpers/arrayItem";
import { formatTimestamp } from "@/helpers/formatTimestamp";
import { getNetworkById } from "@/helpers/getNetworkById";
import { NetworkName } from "@/components/NetworkName";

import { useStore } from "@/store/useStore";

import { trackEvent, TrackingEvent } from "@/metrics/tracking";
Expand Down Expand Up @@ -162,7 +164,7 @@ export const SavedEndpointsPage = () => {

const HorizonEndpoints = () => {
if (savedEndpointsHorizon.length === 0) {
return `There are no saved Horizon Endpoints on ${network.label} network`;
return <span>There are no saved Horizon Endpoints on{" "}<NetworkName>{network.label}</NetworkName>{" "}network</span>;
}

return (
Expand Down Expand Up @@ -272,7 +274,7 @@ export const SavedEndpointsPage = () => {

const RpcEndpoints = () => {
if (savedRpcMethods.length === 0) {
return `There are no saved RPC Methods ${network.label} network`;
return <span>There are no saved RPC Methods on{" "}<NetworkName>{network.label}</NetworkName>{" "}network</span>;
}

return (
Expand Down
5 changes: 4 additions & 1 deletion src/app/(sidebar)/smart-contracts/saved/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { arrayItem } from "@/helpers/arrayItem";
import { delayedAction } from "@/helpers/delayedAction";

import { Routes } from "@/constants/routes";
import { NetworkName } from "@/components/NetworkName";

import { useStore } from "@/store/useStore";
import { trackEvent, TrackingEvent } from "@/metrics/tracking";

Expand Down Expand Up @@ -46,7 +48,8 @@ export default function SavedSmartContracts() {
<Box gap="md">
<>
{savedContracts.length === 0
? `There are no saved smart contracts on ${network.label} network.`
? <span>There are no saved smart contracts on{" "}<NetworkName>{network.label}</NetworkName>{" "}network.</span>

: savedContracts.map((c) => (
<SavedContractItem
key={`saved-contract-${c.timestamp}`}
Expand Down
5 changes: 4 additions & 1 deletion src/app/(sidebar)/transaction/saved/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { SavedItemTimestampAndDelete } from "@/components/SavedItemTimestampAndD
import { PageCard } from "@/components/layout/PageCard";
import { SaveToLocalStorageModal } from "@/components/SaveToLocalStorageModal";

import { NetworkName } from "@/components/NetworkName";

import { useStore } from "@/store/useStore";
import { localStorageSavedTransactions } from "@/helpers/localStorageSavedTransactions";
import { arrayItem } from "@/helpers/arrayItem";
Expand Down Expand Up @@ -230,7 +232,8 @@ export default function SavedTransactions() {
<Box gap="md">
<>
{savedTxns.length === 0
? `There are no saved transactions on ${network.label} network.`
? <span>There are no saved transactions on{" "}<NetworkName>{network.label}</NetworkName>{" "}network.</span>

: savedTxns.map((t) => (
<SavedTxn key={`txn-${t.timestamp}`} txn={t} />
))}
Expand Down
2 changes: 1 addition & 1 deletion src/app/(sidebar)/xdr/diff/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export default function DiffXdr() {
) : null}

<div
className={`CodeEditor ${isExpanded ? "CodeEditor--expanded" : ""}`}
className={`CodeEditor notranslate ${isExpanded ? "CodeEditor--expanded" : ""}`}
data-testid="diff-xdr-editor"
>
<div className="CodeEditor__header">
Expand Down
44 changes: 41 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { Suspense } from "react";
import type { Metadata } from "next";
import { headers } from "next/headers";

import { LayoutMain } from "@/components/layout/LayoutMain";
import { LayoutContextProvider } from "@/components/layout/LayoutContextProvider";
Expand All @@ -9,6 +10,7 @@ import { CustomAiButton } from "@/components/CustomAiButton";
import { QueryProvider } from "@/query/QueryProvider";
import { StoreProvider } from "@/store/StoreProvider";
import { GoogleAnalytics } from "@/metrics/GoogleAnalytics";
import { GoogleTranslateMountPoint } from "@/components/GoogleTranslateMountPoint";

import "@stellar/design-system/build/styles.min.css";
import "@/styles/globals.scss";
Expand All @@ -22,14 +24,49 @@ export const metadata: Metadata = {
// Automatically generates nonce for script and style tags
export const dynamic = "force-dynamic";

export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const nonce = (await headers()).get("x-nonce") ?? "";

return (
<html lang="en">
<body>
<html lang="en" suppressHydrationWarning>
Copy link
Contributor

@jeesunikim jeesunikim Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we really shouldn't enable suppressHydrationWarning.

<head>
{/* Google Translate init — must carry nonce to pass the app's CSP.
suppressHydrationWarning is required because browsers strip nonce
attributes from the DOM after load (to prevent CSS exfiltration),
so the client-side React tree sees nonce="" while the server-rendered
HTML has the real value. The nonce is still present in the raw HTML
that the browser parses for CSP enforcement before React runs. */}
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we really shouldn't use dangerouslySetInnerHTML - this is also likely to be brought up by HackerOne later

__html: `
function googleTranslateElementInit() {
var mount = document.getElementById('google_translate_element');
if (!mount) {
requestAnimationFrame(googleTranslateElementInit);
return;
}
new google.translate.TranslateElement({
pageLanguage: 'en',
autoDisplay: false,
}, 'google_translate_element');
}
`,
}}
/>
<script
src="https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"
nonce={nonce}
suppressHydrationWarning
async
/>
</head>
<body suppressHydrationWarning>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

body doesn't need suppressHydrationWarning

<Suspense>
<div id="root">
<StoreProvider>
Expand All @@ -44,6 +81,7 @@ export default function RootLayout({
</StoreProvider>
</div>
<GoogleAnalytics />
<GoogleTranslateMountPoint />
</Suspense>
</body>
</html>
Expand Down
8 changes: 8 additions & 0 deletions src/components/AssetCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Renders a Stellar asset code (USDC, EURC, etc.) as a non-translatable span.
* Asset codes are technical identifiers that should never be translated by
* browser translation engines like Google Translate.
*/
export const AssetCode = ({ children }: { children: React.ReactNode }) => (
<span className="notranslate">{children}</span>
);
2 changes: 1 addition & 1 deletion src/components/CodeEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const CodeEditor = ({

return (
<div
className={`CodeEditor ${isExpanded ? "CodeEditor--expanded" : ""} ${customCss ? customCss : ""}`}
className={`CodeEditor notranslate ${isExpanded ? "CodeEditor--expanded" : ""} ${customCss ? customCss : ""}`}
style={customStyle}
>
{title ? (
Expand Down
2 changes: 1 addition & 1 deletion src/components/FloatNotification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type FloatNotificationItem = {
id: string;
type: "success";
title: string;
description: string;
description: React.ReactNode;
actions?: {
label: string;
onAction: () => void;
Expand Down
27 changes: 27 additions & 0 deletions src/components/GoogleTranslateMountPoint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import { useEffect } from "react";

/**
* Appends the Google Translate engine mount point to the document body after
* mount, keeping it entirely outside React's reconciliation tree.
*
* Rendering this div in JSX would SSR it as `<div hidden>`, but the Translate
* engine modifies it on the client (removes `hidden`, adds `class="skiptranslate"`,
* injects an `<iframe>`). React's reconciler then sees a mismatch and throws a
* recoverable hydration error. Creating the element imperatively in `useEffect`
* means it is never in the server-rendered HTML, so React never tries to reconcile it.
*/
export const GoogleTranslateMountPoint = () => {
useEffect(() => {
const el = document.createElement("div");
el.id = "google_translate_element";
document.body.appendChild(el);

return () => {
el.remove();
};
}, []);

return null;
};
Loading