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({ )}