diff --git a/.gitignore b/.gitignore
index 304c37ba..5df0354b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
-.prompts/*
\ No newline at end of file
+.prompts/*PRD.md
+PRD.md
+PRD-*.md
diff --git a/app/(home)/sections/10_NewEraQuote.tsx b/app/(home)/sections/10_NewEraQuote.tsx
index acdc8dcd..9f0799a6 100644
--- a/app/(home)/sections/10_NewEraQuote.tsx
+++ b/app/(home)/sections/10_NewEraQuote.tsx
@@ -1,6 +1,8 @@
+import { useTranslations } from "next-intl";
import { Box, Container, Text, Heading } from "@chakra-ui/react";
export default function NewEraQuoteSection() {
+ const t = useTranslations("landing");
return (
- The marker of a new era
+ {t("newEraQuote.attribution")}
World Resources Institute tools have been transforming global
diff --git a/app/(home)/sections/11_CTA.tsx b/app/(home)/sections/11_CTA.tsx
index 3ddfcc55..641d80ac 100644
--- a/app/(home)/sections/11_CTA.tsx
+++ b/app/(home)/sections/11_CTA.tsx
@@ -1,10 +1,13 @@
import { Box, Button, Container, Heading, Text } from "@chakra-ui/react";
+import { useTranslations } from "next-intl";
import Link from "next/link";
import { CaretRightIcon } from "@phosphor-icons/react";
const LANDING_PAGE_VERSION = process.env.NEXT_PUBLIC_LANDING_PAGE_VERSION;
export default function CTASection() {
+ const t = useTranslations("landing");
+ const tc = useTranslations("common");
return (
- How will you use monitoring intelligence?{" "}
+ {t("cta.heading")}
- Join the future of ecosystem monitoring and help us shape what
- comes next.
+ {t("cta.description")}
diff --git a/app/(home)/sections/12_Footer.tsx b/app/(home)/sections/12_Footer.tsx
index ff66bd09..1d4b3023 100644
--- a/app/(home)/sections/12_Footer.tsx
+++ b/app/(home)/sections/12_Footer.tsx
@@ -5,9 +5,11 @@ import {
Text,
Link as ChakraLink,
} from "@chakra-ui/react";
+import { useTranslations } from "next-intl";
import LclLogo from "../../components/LclLogo";
export default function FooterSection() {
+ const t = useTranslations("common");
return (
- Global Nature Watch
+ {t("appName")}
@@ -50,7 +52,7 @@ export default function FooterSection() {
w={{ base: "full", md: "auto" }}
flex={{ md: 2 }}
>
- {new Date().getFullYear()} Global Nature Watch
+ {t("footer.copyrightLong", { year: new Date().getFullYear() })}
- Tackle nature’s toughest monitoring challenges
+ {t("hero.heading")}
- Global Nature Watch is an open, AI-powered system that transforms groundbreaking
- land monitoring data into intelligence to understand Earth’s landscapes.
- Test the preview and help shape the future of land monitoring.
+ {t("hero.description")}
- New Suggestion
+ {t("hero.newSuggestion")}
- Automatically updating in {promptTimer}s
+ {t("hero.autoUpdating", { seconds: promptTimer })}
@@ -282,19 +283,19 @@ export default function LandingHero({
>
- PREVIEW
+ {tc("preview")}
- Global Nature Watch is
+ {t("hero.previewBadgePrefix")}
{LANDING_PAGE_VERSION === "closed"
- ? " in closed preview."
+ ? t("hero.previewBadgeClosed")
: LANDING_PAGE_VERSION === "limited"
- ? " in limited preview."
- : " in preview."}
+ ? t("hero.previewBadgeLimited")
+ : t("hero.previewBadgePublic")}
- What does preview mean?
+ {t("hero.whatDoesPreviewMean")}
diff --git a/app/(home)/sections/3_TrustedPlatforms.tsx b/app/(home)/sections/3_TrustedPlatforms.tsx
index 336a2f2e..ee43d8a8 100644
--- a/app/(home)/sections/3_TrustedPlatforms.tsx
+++ b/app/(home)/sections/3_TrustedPlatforms.tsx
@@ -1,3 +1,4 @@
+import { useTranslations } from "next-intl";
import NextImage from "next/image";
import {
Box,
@@ -45,6 +46,7 @@ const PARTNER_ORGS = [
];
export default function TrustedPlatformsSection() {
+ const t = useTranslations("landing");
return (
- Building upon the legacy of World Resources Institute’s
- trusted platforms
+ {t("trustedPlatforms.heading")}
Global Nature Watch is built on the trusted data and research of Global
diff --git a/app/(home)/sections/4_FeaturesTabs.tsx b/app/(home)/sections/4_FeaturesTabs.tsx
index 18809dfd..746d83d0 100644
--- a/app/(home)/sections/4_FeaturesTabs.tsx
+++ b/app/(home)/sections/4_FeaturesTabs.tsx
@@ -8,41 +8,29 @@ import {
Image,
} from "@chakra-ui/react";
import { CaretRightIcon } from "@phosphor-icons/react";
+import { useTranslations } from "next-intl";
import Link from "next/link";
const LANDING_PAGE_VERSION = process.env.NEXT_PUBLIC_LANDING_PAGE_VERSION;
-const FEATURE_TABS = [
- {
- value: "feature-tab-1",
- label: "Explore trusted data with an AI assistant",
- description:
- "Ask a question in plain language and our assistant will suggest the most useful available datasets and analyses for your work.",
- caption:
- "Quickly find the most relevant data for your work.",
- image: "/feature-tab-1.webp",
- },
- {
- value: "feature-tab-2",
- label: "Tailored answers to your context",
- description:
- "Explore how Global Nature Watch's assistant can shape responses to your needs, from comparing regions to highlighting local patterns that may be most relevant to your work.",
- caption:
- "Shape responses to your needs.",
- image: "/feature-tab-2.webp",
- },
- {
- value: "feature-tab-3",
- label: "Insights you can act on",
- description:
- "Global Nature Watch's assistant helps translate analyses into clear takeaways. It offers a starting point for reports, policies or field decisions while opening the door to dive deeper.",
- caption:
- "Generate clear takeaways from complex data.",
- image: "/feature-tab-3.webp",
- },
+const TAB_IMAGES = [
+ "/feature-tab-1.webp",
+ "/feature-tab-2.webp",
+ "/feature-tab-3.webp",
];
export default function FeaturesTabsSection() {
+ const t = useTranslations("landing");
+ const tc = useTranslations("common");
+
+ const tabs = TAB_IMAGES.map((image, i) => ({
+ value: `feature-tab-${i + 1}`,
+ label: t(`features.tabs.${i}.label`),
+ description: t(`features.tabs.${i}.description`),
+ caption: t(`features.tabs.${i}.caption`),
+ image,
+ }));
+
return (
- Get answers to your toughest questions about landscapes, backed by data
+ {t("features.heading")}
- Global Nature Watch is testing new ways to make geospatial information easier to use.
- Try asking in plain language and explore the insights it can provide.
+ {t("features.description")}
{LANDING_PAGE_VERSION !== "closed" && (
@@ -82,7 +69,7 @@ export default function FeaturesTabsSection() {
defaultValue="feature-tab-1"
>
- {FEATURE_TABS.map((tab) => (
+ {tabs.map((tab) => (
))}
- {FEATURE_TABS.map((tab) => (
+ {tabs.map((tab) => (
- See how monitoring intelligence can support your work
+ {t("supportWork.heading")}
Global Nature Watch makes advanced monitoring data easy to use,
diff --git a/app/(home)/sections/6_HowItWorks.tsx b/app/(home)/sections/6_HowItWorks.tsx
index 47c44f48..93edc0c3 100644
--- a/app/(home)/sections/6_HowItWorks.tsx
+++ b/app/(home)/sections/6_HowItWorks.tsx
@@ -1,45 +1,35 @@
import { Box, Container, Flex, Heading, Image, Text } from "@chakra-ui/react";
+import { useTranslations } from "next-intl";
-const HOW_STEPS = [
- {
- title: "Processing your intent",
- description:
- "When you ask Global Nature Watch a question, our system of agents work together to understand your request and deliver the most accurate, relevant answers.",
- images: [
- { src: "/Langchain-logo.svg", alt: "Langchain logo", width: "80px" },
- { src: "/gemini-icon.svg", alt: "Gemini icon", width: "50px" },
- { src: "/claude-ai-icon.svg", alt: "Claude AI icon", width: "50px" },
- ],
- },
- {
- title: "Retrieving quality data",
- description:
- "Our data comes via APIs from Global Forest Watch and Land & Carbon Lab, platforms powered by contributions from researchers and partners around the globe. This means verifiable data from authoritative sources.",
- images: [
- { src: "/GFW-logo.svg", alt: "GFW logo", maxW: "100%" },
- { src: "LCL-logo.svg", alt: "LCL logo", maxW: "100%" },
- ],
- },
- {
- title: "Tuning the AI model's response",
- description:
- "We use Retrieval-Augmented Generation (RAG) to link data retrieved via our trusted APIs with real documentation, methods papers and metadata from our research.",
- images: [{ src: "/ri_chat-ai-line.svg", alt: "AI icon", maxW: "100%" }],
- },
- {
- title: "Returning a response",
- description:
- "Our agents create summaries of spatial data, search through datasets, and explain insights clearly in over 170 languages.",
- images: [
- {
- src: "/HIW-Brazil-Widget.png",
- alt: "Brazil widget example",
- maxW: "100%",
- },
- ],
- },
+const STEP_IMAGES = [
+ [
+ { src: "/Langchain-logo.svg", alt: "Langchain logo", width: "80px" },
+ { src: "/gemini-icon.svg", alt: "Gemini icon", width: "50px" },
+ { src: "/claude-ai-icon.svg", alt: "Claude AI icon", width: "50px" },
+ ],
+ [
+ { src: "/GFW-logo.svg", alt: "GFW logo", maxW: "100%" },
+ { src: "LCL-logo.svg", alt: "LCL logo", maxW: "100%" },
+ ],
+ [{ src: "/ri_chat-ai-line.svg", alt: "AI icon", maxW: "100%" }],
+ [
+ {
+ src: "/HIW-Brazil-Widget.png",
+ alt: "Brazil widget example",
+ maxW: "100%",
+ },
+ ],
];
+
export default function HowItWorksSection() {
+ const t = useTranslations("landing");
+
+ const steps = STEP_IMAGES.map((images, i) => ({
+ title: t(`howItWorks.steps.${i}.title`),
+ description: t(`howItWorks.steps.${i}.description`),
+ images,
+ }));
+
return (
- How it works
+ {t("howItWorks.heading")}
- Where are the most disturbances to nature happening now?
+ {t("howItWorks.userQuestion")}
- {HOW_STEPS.map((step, index) => (
+ {steps.map((step, index) => (
))}
- {/* Animated line */}
- Latest updates & research
+ {t("latestUpdates.heading")}
Global Nature Watch serves up the latest breakthroughs in geospatial data
diff --git a/app/(home)/sections/8_FutureOfMonitoring.tsx b/app/(home)/sections/8_FutureOfMonitoring.tsx
index b0cbe991..2be0d3aa 100644
--- a/app/(home)/sections/8_FutureOfMonitoring.tsx
+++ b/app/(home)/sections/8_FutureOfMonitoring.tsx
@@ -1,3 +1,4 @@
+import { useTranslations } from "next-intl";
import NextImage from "next/image";
import {
Box,
@@ -8,6 +9,7 @@ import {
} from "@chakra-ui/react";
export default function FutureOfMonitoringSection() {
+ const t = useTranslations("landing");
return (
- About Global Nature Watch
+ {t("futureMonitoring.heading")}
We're making environmental geospatial data faster,
diff --git a/app/(home)/sections/9_TeamSection.tsx b/app/(home)/sections/9_TeamSection.tsx
index fc0fe815..c53729e9 100644
--- a/app/(home)/sections/9_TeamSection.tsx
+++ b/app/(home)/sections/9_TeamSection.tsx
@@ -1,3 +1,4 @@
+import { useTranslations } from "next-intl";
import NextImage from "next/image";
import {
Box,
@@ -9,6 +10,7 @@ import {
} from "@chakra-ui/react";
export default function TeamSection() {
+ const t = useTranslations("landing");
return (
- The team behind Global Nature Watch
+ {t("team.heading")}
Global Nature Watch is developed by Land & Carbon Lab,
diff --git a/app/ChatPanel.tsx b/app/ChatPanel.tsx
index 1f349cab..452780d7 100644
--- a/app/ChatPanel.tsx
+++ b/app/ChatPanel.tsx
@@ -8,11 +8,13 @@ import ChatInput from "./components/ChatInput";
import ChatMessages from "./components/ChatMessages";
import ChatStatusInfo from "./components/ChatStatusInfo";
import ChatPanelHeader from "./ChatPanelHeader";
+import { useTranslations } from "next-intl";
import useAuthStore from "./store/authStore";
const [minWidth, maxWidth, defaultWidth] = [384, 624, 592];
function ChatPanel() {
+ const t = useTranslations("chat");
const { usedPrompts, totalPrompts, isAnonymous } = useAuthStore();
const promptsExhausted = usedPrompts >= totalPrompts;
@@ -91,7 +93,7 @@ function ChatPanel() {
(isAnonymous ? (
- You've used all your guest prompts.
+ {t("prompts.exhaustedGuest.title")}
- Log in or sign up for free
- {" "}
- to unlock extra daily prompts, or{" "}
+ {t("prompts.exhaustedGuest.loginLink")}
+
+ {t("prompts.exhaustedGuest.unlockText")}
- continue without AI
+ {t("prompts.exhaustedGuest.classicLink")}
.
@@ -112,13 +114,12 @@ function ChatPanel() {
- You've reached today's limit of {totalPrompts}{" "}
- prompts.
+ {t("prompts.exhaustedAuth.title", { total: totalPrompts })}
- Wait until tomorrow for new prompts, or{" "}
+ {t("prompts.exhaustedAuth.waitText")}
- continue without AI
+ {t("prompts.exhaustedAuth.classicLink")}
.
@@ -135,8 +136,7 @@ function ChatPanel() {
gap={2}
>
- AI makes mistakes. Verify outputs and do not share
- any sensitive or personal information.
+ {t("disclaimer")}
diff --git a/app/ChatPanelHeader.tsx b/app/ChatPanelHeader.tsx
index aac74818..a70ee951 100644
--- a/app/ChatPanelHeader.tsx
+++ b/app/ChatPanelHeader.tsx
@@ -21,6 +21,7 @@ import {
ChartPolarIcon,
} from "@phosphor-icons/react";
import Link from "next/link";
+import { useTranslations } from "next-intl";
import { Tooltip } from "./components/ui/tooltip";
import useSidebarStore from "./store/sidebarStore";
@@ -49,10 +50,12 @@ function ChatPanelHeader() {
} = useSidebarStore();
const { currentThreadId, messages } = useChatStore();
+ const t = useTranslations("chat");
+ const tc = useTranslations("common");
const currentThread = getThreadById(currentThreadId);
const currentThreadName = currentThread
? currentThread.name
- : "New Conversation";
+ : t("panelHeader.newConversation");
// Build list of widget anchors from chat messages
const widgetAnchors = useMemo(() => {
@@ -75,8 +78,8 @@ function ChatPanelHeader() {
const d = new Date(isoTs);
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const day = d.toLocaleDateString([], { day: "2-digit", month: "short" });
- return `${time} on ${day}`;
- }, []);
+ return t("panelHeader.widgetMeta", { time, day });
+ }, [t]);
const scrollToWidget = useCallback((anchorId: string) => {
const el = document.getElementById(anchorId);
@@ -101,7 +104,7 @@ function ChatPanelHeader() {
>
{!sideBarVisible && (
@@ -174,7 +177,7 @@ function ChatPanelHeader() {
{/* Insights dropdown */}
{widgetAnchors.length === 0 ? (
-
+
@@ -208,7 +211,7 @@ function ChatPanelHeader() {
}}
size="xs"
>
- Go to insight
+ {t("panelHeader.goToInsight")}
@@ -274,9 +277,9 @@ function ChatPanelHeader() {
)}
{!sideBarVisible && (
-
+
-
+
diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts
index 2a7bd646..4fb9f501 100644
--- a/app/api/auth/me/route.ts
+++ b/app/api/auth/me/route.ts
@@ -32,6 +32,7 @@ export async function GET() {
// Attempt to fetch prompts usage/quota from upstream
let promptsUsed: number | null = null;
let promptQuota: number | null = null;
+ let preferredLanguageCode: string | null = null;
try {
const upstream = await fetch(`${API_CONFIG.API_BASE_URL}/auth/me`, {
@@ -69,6 +70,10 @@ export async function GET() {
promptsUsed = typeof used === "number" ? used : null;
promptQuota = typeof quota === "number" ? quota : null;
hasProfile = Boolean(data?.hasProfile ?? data?.user?.hasProfile);
+ preferredLanguageCode =
+ typeof data?.preferredLanguageCode === "string"
+ ? data.preferredLanguageCode
+ : null;
} catch (err) {
return NextResponse.json(
{ error: (err as Error)?.message || "Internal error" },
@@ -106,6 +111,7 @@ export async function GET() {
promptsUsed,
promptQuota,
hasProfile,
+ preferredLanguageCode,
},
{
headers: {
diff --git a/app/components/ChatDisclaimer.tsx b/app/components/ChatDisclaimer.tsx
index 6aaf6d83..e024701b 100644
--- a/app/components/ChatDisclaimer.tsx
+++ b/app/components/ChatDisclaimer.tsx
@@ -1,3 +1,4 @@
+import { useTranslations } from "next-intl";
import { Box, CloseButton, type BoxProps } from "@chakra-ui/react";
import {
CheckCircleIcon,
@@ -32,6 +33,7 @@ export default function ChatDisclaimer({
children,
...boxProps
}: ChatDisclaimerProps) {
+ const t = useTranslations("chat");
const IconComponent = TypeIcon[type];
return (
@@ -60,7 +62,7 @@ export default function ChatDisclaimer({
size="2xs"
variant="ghost"
colorPalette={typeColorMap[type]}
- title="Hide disclaimer"
+ title={t("hideDisclaimer")}
onClick={() => setDisplayDisclaimer((prev) => !prev)}
/>
)}
diff --git a/app/components/ChatInput.tsx b/app/components/ChatInput.tsx
index 1ebc7ac5..6abac6a5 100644
--- a/app/components/ChatInput.tsx
+++ b/app/components/ChatInput.tsx
@@ -11,6 +11,7 @@ import {
Portal,
} from "@chakra-ui/react";
import { ArrowBendRightUpIcon } from "@phosphor-icons/react";
+import { useTranslations } from "next-intl";
import useChatStore from "@/app/store/chatStore";
import ContextButton, { ChatContextType } from "./ContextButton";
import ContextTag from "./ContextTag";
@@ -82,8 +83,9 @@ export default function ChatInput({
// If Shift+Enter, do nothing: allow newline
};
+ const t = useTranslations("chat");
const disabled = isLoading || isChatDisabled;
- const message = isLoading ? "Sending..." : "Ask a question...";
+ const message = isLoading ? t("input.sending") : t("input.placeholder");
const isButtonDisabled = disabled || !inputValue?.trim();
const hasContext = context.length > 0;
@@ -128,7 +130,7 @@ export default function ChatInput({
)}
diff --git a/app/components/DatasetInfoModal.tsx b/app/components/DatasetInfoModal.tsx
index e680e72b..56797b96 100644
--- a/app/components/DatasetInfoModal.tsx
+++ b/app/components/DatasetInfoModal.tsx
@@ -8,6 +8,7 @@ import {
Separator,
} from "@chakra-ui/react";
import { XIcon } from "@phosphor-icons/react";
+import { useTranslations } from "next-intl";
import { DatasetInfo } from "@/app/types/chat";
import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks";
@@ -23,6 +24,7 @@ export function DatasetInfoModal({
onClose,
dataset,
}: DatasetInfoModalProps) {
+ const t = useTranslations("chat");
return (
!e.open && onClose()}>
@@ -34,7 +36,7 @@ export function DatasetInfoModal({
- Description
+ {t("dataset.description")}
@@ -46,7 +48,7 @@ export function DatasetInfoModal({
{dataset.methodology && (
- Methodology
+ {t("dataset.methodology")}
@@ -59,7 +61,7 @@ export function DatasetInfoModal({
{dataset.cautions && (
- Cautions
+ {t("dataset.cautions")}
@@ -72,7 +74,7 @@ export function DatasetInfoModal({
{dataset.citation && (
- Citation
+ {t("dataset.citation")}
diff --git a/app/components/GlobalHeader.tsx b/app/components/GlobalHeader.tsx
index 8b6bdd9e..138837e1 100644
--- a/app/components/GlobalHeader.tsx
+++ b/app/components/GlobalHeader.tsx
@@ -13,15 +13,20 @@ import {
Text,
} from "@chakra-ui/react";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import Link from "next/link";
import LclLogo from "./LclLogo";
const LANDING_PAGE_VERSION = process.env.NEXT_PUBLIC_LANDING_PAGE_VERSION;
-const renderNavItems = (
- isMobile: boolean,
- setNavOpen?: (open: boolean) => void | undefined
-): React.ReactElement | null => {
+function NavItems({
+ isMobile,
+ setNavOpen,
+}: {
+ isMobile: boolean;
+ setNavOpen?: (open: boolean) => void;
+}) {
+ const t = useTranslations("common");
return (
setNavOpen && setNavOpen(false)}>
- Use cases
+ {t("nav.useCases")}
setNavOpen && setNavOpen(false)}>
- Technology
+ {t("nav.technology")}
setNavOpen && setNavOpen(false)}>
- Research
+ {t("nav.research")}
setNavOpen && setNavOpen(false)}>
- About
+ {t("nav.about")}
{LANDING_PAGE_VERSION === "closed" && (
setNavOpen && setNavOpen(false)}
>
- Sign in (invite only)
+ {t("nav.signIn")}
)}
- Join waitlist
+ {t("nav.joinWaitlist")}
) : (
setNavOpen && setNavOpen(false)}>
- Explore the preview
+ {t("nav.explorePreview")}
)}
);
-};
+}
export default function GlobalHeader() {
+ const t = useTranslations("common");
const [openNav, setNavOpen] = useState(false);
return (
- Global Nature Watch
+ {t("appName")}
- Turning intelligent monitoring into impact
+ {t("tagline")}
- Menu
+ {t("nav.menu")}
@@ -163,10 +169,12 @@ export default function GlobalHeader() {
>
- Global Nature Watch
+ {t("appName")}
- {renderNavItems(true, setNavOpen)}
+
+
+
- {renderNavItems(false)}
+
);
diff --git a/app/components/InsightProvenanceDrawer.tsx b/app/components/InsightProvenanceDrawer.tsx
index c66b2375..fb56f431 100644
--- a/app/components/InsightProvenanceDrawer.tsx
+++ b/app/components/InsightProvenanceDrawer.tsx
@@ -24,6 +24,7 @@ import { fetchExternalData } from "@/app/actions/fetch-data";
import { Tooltip } from "@/app/components/ui/tooltip";
import JSZip from "jszip";
import Image from "next/image";
+import { useTranslations } from "next-intl";
interface InsightProvenanceDrawerProps {
isOpen: boolean;
@@ -110,6 +111,7 @@ function extractDataUrls(code: string): string[] {
// --- Components ---
function CodeBlockViewer({ code }: { code: string }) {
+ const t = useTranslations("chat");
const { copy, copied } = useClipboard({ value: code });
const [downloading, setDownloading] = useState(false);
@@ -140,7 +142,7 @@ function CodeBlockViewer({ code }: { code: string }) {
}
} catch (err) {
console.error("Download error:", err);
- alert("Failed to download data.");
+ alert(t("provenance.downloadFailed"));
} finally {
setDownloading(false);
}
@@ -165,29 +167,29 @@ function CodeBlockViewer({ code }: { code: string }) {
- Code
+ {t("provenance.code")}
{dataUrls.length > 0 && (
-
+
)}
-
+
{copied ? : }
@@ -220,6 +222,8 @@ export default function InsightProvenanceDrawer({
generation,
title,
}: InsightProvenanceDrawerProps) {
+ const t = useTranslations("chat");
+ const tc = useTranslations("common");
const parts = generation?.codeact_parts || [];
return (
@@ -243,7 +247,7 @@ export default function InsightProvenanceDrawer({
{title
? `${title}`
- : "How this was generated"}
+ : t("provenance.defaultTitle")}
- Close
+ {tc("buttons.close")}
{parts.length === 0 ? (
- No generation details available.
+ {t("provenance.noDetails")}
) : (
@@ -332,16 +336,16 @@ export default function InsightProvenanceDrawer({
- Execution output
+ {t("provenance.executionOutput")}
-
+ navigator.clipboard.writeText(content)}
- aria-label="Copy output"
+ aria-label={t("provenance.copyOutput")}
>
@@ -367,7 +371,7 @@ export default function InsightProvenanceDrawer({
generation.source_urls.length > 0 && (
- Sources
+ {t("provenance.sources")}
{generation.source_urls.map((url, idx) => (
diff --git a/app/components/LanguageSelector.tsx b/app/components/LanguageSelector.tsx
new file mode 100644
index 00000000..6dc075a0
--- /dev/null
+++ b/app/components/LanguageSelector.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { Button, Box, Text, Separator } from "@chakra-ui/react";
+import { TranslateIcon, InfoIcon } from "@phosphor-icons/react";
+import { useState, useRef, useEffect } from "react";
+import { useTranslations } from "next-intl";
+import useAuthStore from "@/app/store/authStore";
+import { SUPPORTED_LANGUAGES } from "@/app/config/languages";
+import { toaster } from "@/app/components/ui/toaster";
+
+/**
+ * A small pill button that shows the current language code and opens a
+ * popover-style selector on click. Includes an "Other Languages…" option
+ * that explains the assistant understands many more languages.
+ */
+export default function LanguageSelector({
+ disabled,
+ dropDirection = "down",
+}: {
+ disabled?: boolean;
+ dropDirection?: "up" | "down";
+}) {
+ const t = useTranslations("common");
+ const preferredLanguageCode = useAuthStore((s) => s.preferredLanguageCode);
+ const setPreferredLanguage = useAuthStore((s) => s.setPreferredLanguage);
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ const currentCode = preferredLanguageCode ?? "en";
+ const currentLabel =
+ SUPPORTED_LANGUAGES.find((l) => l.value === currentCode)?.label ??
+ "English";
+
+ // Close on outside click
+ useEffect(() => {
+ if (!isOpen) return;
+ function handleClick(e: MouseEvent) {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [isOpen]);
+
+ const handleOtherLanguages = () => {
+ setIsOpen(false);
+ toaster.create({
+ title: t("languageSelector.otherToastTitle"),
+ description: t("languageSelector.otherToastDescription"),
+ type: "info",
+ duration: 8000,
+ });
+ };
+
+ return (
+
+ setIsOpen((o) => !o)}
+ title={currentLabel}
+ >
+
+ {currentCode.toUpperCase()}
+
+
+ {isOpen && (
+
+ {/* Section label */}
+
+ {t("languageSelector.sectionLabel")}
+
+
+ {/* Selectable languages */}
+ {SUPPORTED_LANGUAGES.map((lang) => {
+ const isActive = lang.value === currentCode;
+ return (
+ {
+ setPreferredLanguage(lang.value);
+ setIsOpen(false);
+ }}
+ >
+
+ {lang.label}
+
+
+ {lang.value.toUpperCase()}
+
+
+ );
+ })}
+
+ {/* Divider + Other Languages */}
+
+
+
+
+ {t("languageSelector.otherLanguages")}
+
+
+
+ {/* AI translation disclaimer */}
+
+
+ {t("languageSelector.aiDisclaimer")}
+
+
+ )}
+
+ );
+}
diff --git a/app/components/MapAreaControls.tsx b/app/components/MapAreaControls.tsx
index a0901d5c..f5a17549 100644
--- a/app/components/MapAreaControls.tsx
+++ b/app/components/MapAreaControls.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
import {
Box,
type BoxProps,
@@ -64,6 +65,7 @@ function MapAreaControls({
basemapTiles,
setBasemapTiles,
}: MapAreaControlsProps) {
+ const t = useTranslations("chat");
const {
setSelectAreaLayer,
isDrawingMode,
@@ -164,7 +166,7 @@ function MapAreaControls({
onClick={() => setShowTools((prev) => !prev)}
>
{!showTools ? : }
- Tools
+ {t("map.tools")}
{isDrawingMode ? (
<>
-
+
-
+
@@ -206,9 +208,9 @@ function MapAreaControls({
>
) : (
<>
-
+ {
@@ -226,7 +228,7 @@ function MapAreaControls({
-
+
@@ -257,7 +259,7 @@ function MapAreaControls({
borderLeftRadius={0}
bg="bg"
_hover={{ bg: "bg.muted" }}
- aria-label="Select area from options"
+ aria-label={t("map.selectAreaFromOptions")}
>
@@ -285,11 +287,11 @@ function MapAreaControls({
-
+ {
startDrawing();
setSelectionMode({ type: "Drawing", name: undefined });
@@ -320,8 +322,11 @@ function MapAreaControls({
boxShadow="sm"
color="blackAlpha.700"
>
- {selectionMode.type}{" "}
- {selectionMode.type === "Selecting" ? selectionMode.name : "AOI"}
+ {selectionMode.type === "Selecting"
+ ? t("map.selectingMode", { name: selectionMode.name ?? "" })
+ : selectionMode.type === "Drawing"
+ ? t("map.drawingMode")
+ : t("map.uploadingMode")}
)}
@@ -332,6 +337,7 @@ function MapAreaControls({
export default MapAreaControls;
function ValidationErrorDisplay() {
+ const t = useTranslations("chat");
const { validationError, clearValidationError } = useMapStore();
if (!validationError) return null;
@@ -348,7 +354,7 @@ function ValidationErrorDisplay() {
position="relative"
order={{ base: -1, md: "initial" }}
>
-
+
@@ -367,12 +373,12 @@ function ValidationErrorDisplay() {
{validationError.code === "too-small"
- ? "Error: Area too small"
- : "Error: Area too large"}
+ ? t("map.errorAreaTooSmall")
+ : t("map.errorAreaTooLarge")}
- {validationError.code === "too-small" ? "Minimum" : "Maximum"} area
+ {validationError.code === "too-small" ? t("map.minimumArea") : t("map.maximumArea")}
{validationError.code === "too-small"
@@ -381,7 +387,7 @@ function ValidationErrorDisplay() {
- Your area
+ {t("map.yourArea")}{formatAreaWithUnits(validationError.area)}
diff --git a/app/components/MessageBubble.tsx b/app/components/MessageBubble.tsx
index 62580410..7b3c207b 100644
--- a/app/components/MessageBubble.tsx
+++ b/app/components/MessageBubble.tsx
@@ -29,6 +29,7 @@ import { ContextItem } from "../store/contextStore";
import { useEffect, useState, useCallback } from "react";
import remarkBreaks from "remark-breaks";
import { WarningIcon } from "@phosphor-icons/react";
+import { useTranslations } from "next-intl";
import useChatStore from "../store/chatStore";
import { toaster } from "./ui/toaster";
import CopySelectionTooltip from "./CopySelectionTooltip";
@@ -44,6 +45,8 @@ function MessageBubble({
isConsecutive = false,
isFirst = false,
}: MessageBubbleProps) {
+ const t = useTranslations("chat");
+ const tc = useTranslations("common");
const [formattedTimestamp, setFormattedTimestamp] = useState("");
const clipboard = useClipboard({ value: message.message });
const [isRating, setIsRating] = useState(false);
@@ -64,7 +67,7 @@ function MessageBubble({
day: "2-digit",
month: "short",
});
- setFormattedTimestamp(`${time} on ${day}`);
+ setFormattedTimestamp(t("message.timestamp", { time, day }));
}, [message.timestamp]);
const rateMessage = useCallback(
@@ -75,8 +78,8 @@ function MessageBubble({
const threadId = currentThreadId || "";
if (!threadId) {
toaster.create({
- title: "Unable to rate",
- description: "No active thread.",
+ title: t("message.unableToRate"),
+ description: t("message.noActiveThread"),
type: "error",
});
return;
@@ -96,15 +99,15 @@ function MessageBubble({
const text = await res.text();
console.error("Failed to submit rating", text);
toaster.create({
- title: "Rating failed",
- description: "Please try again.",
+ title: t("message.ratingFailed"),
+ description: t("message.ratingFailedDescription"),
type: "error",
});
} else {
if (ratingValue === 1) {
toaster.create({
- title: "Thanks for the feedback",
- description: "Glad it helped!",
+ title: t("message.feedbackThanks"),
+ description: t("message.feedbackThanksDescription"),
duration: 2500,
type: "success",
});
@@ -112,10 +115,10 @@ function MessageBubble({
const hasComment =
typeof comment === "string" && comment.trim().length > 0;
toaster.create({
- title: hasComment ? "Feedback sent" : "Marked as not helpful",
+ title: hasComment ? t("message.feedbackSent") : t("message.markedNotHelpful"),
description: hasComment
- ? "Thanks for helping us improve."
- : "You can add a comment.",
+ ? t("message.feedbackSentDescription")
+ : t("message.markedNotHelpfulDescription"),
duration: 2500,
type: "success",
});
@@ -182,7 +185,7 @@ function MessageBubble({
{hasContext && (
- Context:
+ {t("context.label")}
{message.context?.map((c: ContextItem) => (
: }
-
+ setFeedbackOpen(e.open)}
positioning={{ placement: "bottom-end" }}
>
-
+ setFeedbackText(e.target.value)}
- placeholder="Tell us what went wrong (optional)"
+ placeholder={t("message.feedbackPlaceholder")}
size="sm"
rows={3}
/>
@@ -348,7 +351,7 @@ function MessageBubble({
setFeedbackText("");
}}
>
- Cancel
+ {tc("buttons.cancel")}
- Send feedback
+ {t("message.sendFeedback")}
diff --git a/app/components/PageHeader.tsx b/app/components/PageHeader.tsx
index 98f2591e..26976497 100644
--- a/app/components/PageHeader.tsx
+++ b/app/components/PageHeader.tsx
@@ -20,19 +20,22 @@ import {
InfoIcon,
} from "@phosphor-icons/react";
import { Tooltip } from "./ui/tooltip";
+import { useTranslations } from "next-intl";
import useAuthStore from "../store/authStore";
import Link from "next/link";
import { toaster } from "@/app/components/ui/toaster";
+import LanguageSelector from "./LanguageSelector";
function PageHeader() {
+ const t = useTranslations("common");
const { userEmail, usedPrompts, totalPrompts, isAuthenticated } =
useAuthStore();
const handleLogout = async () => {
try {
toaster.create({
- title: "Logging out",
- description: "Signing you out and redirecting…",
+ title: t("auth.loggingOut"),
+ description: t("auth.loggingOutDescription"),
type: "info",
duration: 8000,
});
@@ -67,7 +70,7 @@ function PageHeader() {
>
- Global Nature Watch
+ {t("appName")}
- PREVIEW
+ {t("preview")}
-
+
+
- Help
+ {t("buttons.help")}
@@ -99,6 +103,8 @@ function PageHeader() {
max={100}
value={(usedPrompts / totalPrompts) * 100}
minW="6rem"
+ px="4"
+ py="1.5"
textAlign="center"
rounded="full"
colorPalette="primary"
@@ -117,12 +123,15 @@ function PageHeader() {
) : (
totalPrompts
)}{" "}
- daily prompts
+ {t("header.dailyPrompts")}
5000
- ? "You have unlimited prompts!"
- : `${usedPrompts} of ${totalPrompts} prompts used. Prompts refresh every 24 hours.`
+ totalPrompts > 5000
+ ? t("header.unlimitedPrompts")
+ : t("header.promptsUsageTooltip", {
+ used: usedPrompts,
+ total: totalPrompts,
+ })
}
showArrow
>
@@ -151,7 +160,7 @@ function PageHeader() {
size="sm"
>
- {userEmail || "User name"}
+ {userEmail || t("header.userName")}
@@ -160,7 +169,7 @@ function PageHeader() {
- Settings
+ {t("header.settings")}
@@ -170,10 +179,10 @@ function PageHeader() {
color="fg.error"
_hover={{ bg: "bg.error", color: "fg.error" }}
onClick={handleLogout}
- title="Log Out"
+ title={t("header.logout")}
>
- Logout
+ {t("header.logout")}
@@ -189,7 +198,7 @@ function PageHeader() {
>
- Log in / Sign Up
+ {t("header.loginSignup")}
)}
diff --git a/app/components/Reasoning.tsx b/app/components/Reasoning.tsx
index 770a5294..53f6e026 100644
--- a/app/components/Reasoning.tsx
+++ b/app/components/Reasoning.tsx
@@ -2,30 +2,30 @@
import { Box, Collapsible, Flex, Text, Spinner } from "@chakra-ui/react";
import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import useChatStore from "@/app/store/chatStore";
-// Helper function to format tool names for display
-function formatToolName(toolName: string): string {
- const toolNameMap: Record = {
- generate_insights: "Generating insights",
- pick_aoi: "Picking area of interest",
- pick_dataset: "Selecting dataset",
- pull_data: "Pulling data",
- };
-
- return toolNameMap[toolName] || `Processing ${toolName}`;
-}
-
function Reasoning() {
+ const t = useTranslations("chat");
const [isOpen, setIsOpen] = useState(false);
const { toolSteps } = useChatStore();
+
+ const formatToolName = (toolName: string): string => {
+ const key = `reasoning.tools.${toolName}` as const;
+ try {
+ return t(key);
+ } catch {
+ return t("reasoning.toolFallback", { name: toolName });
+ }
+ };
+
return (
setIsOpen(e.open)}>
- Reasoning
+ {t("reasoning.title")}
{isOpen ? : }
@@ -44,7 +44,7 @@ function Reasoning() {
fontSize="xs"
>
{toolSteps.length === 0 ? (
- Processing request...
+ {t("reasoning.processing")}
) : (
toolSteps.map((toolName, index) => (
diff --git a/app/components/SamplePrompts.tsx b/app/components/SamplePrompts.tsx
index e47924de..d8a32f6c 100644
--- a/app/components/SamplePrompts.tsx
+++ b/app/components/SamplePrompts.tsx
@@ -11,10 +11,10 @@ export default function SamplePrompts() {
const router = useRouter();
useEffect(() => {
- if (prompts.length > 0 && samplePrompts.length === 0) {
+ if (prompts.length > 0) {
setSamplePrompts(getRandomFromArray(prompts, 3));
}
- }, [prompts, samplePrompts.length]);
+ }, [prompts]);
const submitPrompt = async (prompt: string) => {
const result = await sendMessage(prompt);
diff --git a/app/components/ThreadActionsMenu.tsx b/app/components/ThreadActionsMenu.tsx
index e4525596..02478a0e 100644
--- a/app/components/ThreadActionsMenu.tsx
+++ b/app/components/ThreadActionsMenu.tsx
@@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
+import { useTranslations } from "next-intl";
import { Menu, IconButton } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import useSidebarStore from "../store/sidebarStore";
@@ -22,6 +23,7 @@ function ThreadActionsMenu({
thread: { id: string; name: string, is_public?: boolean };
children?: React.ReactNode;
}) {
+ const t = useTranslations("dialogs");
const router = useRouter();
const { renameThread, shareThread, deleteThread } = useSidebarStore();
const { currentThreadId } = useChatStore();
@@ -70,7 +72,7 @@ function ThreadActionsMenu({
{children || (
setRenameOpen(true)}
>
- Rename
+ {t("thread.rename")}
setShareOpen(true)}
>
- Share
+ {t("thread.share")}
setDeleteOpen(true)}
>
- Delete
+ {t("thread.delete")}
diff --git a/app/components/ThreadDeleteDialog.tsx b/app/components/ThreadDeleteDialog.tsx
index c56f7389..db05a857 100644
--- a/app/components/ThreadDeleteDialog.tsx
+++ b/app/components/ThreadDeleteDialog.tsx
@@ -1,4 +1,5 @@
import { Dialog, Button, Portal } from "@chakra-ui/react";
+import { useTranslations } from "next-intl";
interface ThreadDeleteDialogProps {
threadName: string;
@@ -8,6 +9,8 @@ interface ThreadDeleteDialogProps {
}
function ThreadDeleteDialog(props: ThreadDeleteDialogProps) {
+ const t = useTranslations("dialogs");
+ const tc = useTranslations("common");
const { threadName, onConfirm, isOpen, onOpenChange } = props;
return (
@@ -21,17 +24,16 @@ function ThreadDeleteDialog(props: ThreadDeleteDialogProps) {
- Are you sure?
+ {t("deleteThread.title")}
- This action cannot be undone. This will permanently delete the
- conversation {threadName} from our systems.
+ {t("deleteThread.body", { name: threadName })}