diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 25529f86..dea2db0c 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
-import { PreloadTerminalData } from "@/components/landing/PreloadTerminalData";
import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLandingPage";
export const metadata: Metadata = {
@@ -69,7 +68,6 @@ export default async function HomePage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
-
>
);
diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx
index c17a4cb0..a33101ce 100644
--- a/frontend/components/landing/InstitutionalLandingPage.tsx
+++ b/frontend/components/landing/InstitutionalLandingPage.tsx
@@ -1,27 +1,15 @@
-"use client";
-
-import { useEffect, useState } from "react";
import Link from "next/link";
+import { cookies, headers } from "next/headers";
+import { LandingAnalytics } from "@/components/landing/LandingAnalytics";
import {
- ArrowRight,
- Bell,
- Check,
- CheckCircle2,
- Clock3,
- CloudSun,
- Database,
- Gauge,
- LineChart,
- Radar,
- ShieldCheck,
- Users,
-} from "lucide-react";
-import { I18nProvider, useI18n } from "@/hooks/useI18n";
+ LandingHeaderActions,
+ LandingHeroActions,
+} from "@/components/landing/LandingAuthActions";
import {
- getSupabaseBrowserClient,
- hasSupabasePublicEnv,
-} from "@/lib/supabase/client";
-import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
+ LANDING_LOCALE_COOKIE,
+ pickLandingLocale,
+ type LandingLocale,
+} from "@/components/landing/landingLocale";
const COVERAGE_EN = [
"Live airport observations",
@@ -59,6 +47,123 @@ const PRO_FEATURES_ZH = [
"订阅与准入问题优先支持",
];
+type IconName =
+ | "radar"
+ | "gauge"
+ | "shield"
+ | "cloudSun"
+ | "lineChart"
+ | "bell"
+ | "clock"
+ | "database"
+ | "check"
+ | "arrow";
+
+function LandingIcon({
+ className,
+ name,
+ size = 16,
+}: {
+ className?: string;
+ name: IconName;
+ size?: number;
+}) {
+ const common = {
+ "aria-hidden": true,
+ className,
+ fill: "none",
+ height: size,
+ stroke: "currentColor",
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ strokeWidth: 2,
+ viewBox: "0 0 24 24",
+ width: size,
+ };
+
+ switch (name) {
+ case "radar":
+ return (
+
+
+
+
+
+
+ );
+ case "gauge":
+ return (
+
+
+
+
+
+ );
+ case "shield":
+ return (
+
+
+
+
+ );
+ case "cloudSun":
+ return (
+
+
+
+
+
+
+
+ );
+ case "lineChart":
+ return (
+
+
+
+
+
+ );
+ case "bell":
+ return (
+
+
+
+
+ );
+ case "clock":
+ return (
+
+
+
+
+ );
+ case "database":
+ return (
+
+
+
+
+
+ );
+ case "check":
+ return (
+
+
+
+ );
+ case "arrow":
+ return (
+
+
+
+
+ );
+ default:
+ return null;
+ }
+}
+
function WeatherWorkflowIllustration() {
return (
{
+ const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
+ return pickLandingLocale(
+ cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
+ headerStore.get("accept-language"),
+ );
+}
+
+function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
const isEn = locale === "en-US";
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const [authChecked, setAuthChecked] = useState(false);
-
- useEffect(() => {
- if (markAnalyticsOnce("landing_view", "session")) {
- trackAppEvent("landing_view", { entry: "landing" });
- }
- }, []);
-
- useEffect(() => {
- if (!hasSupabasePublicEnv()) {
- setAuthChecked(true);
- return;
- }
- const supabase = getSupabaseBrowserClient();
- supabase.auth.getSession().then(({ data: { session } }) => {
- setIsAuthenticated(!!session?.user);
- setAuthChecked(true);
- });
- }, []);
-
const coverage = isEn ? COVERAGE_EN : COVERAGE_ZH;
+ const coverageAccentClasses = [
+ "bg-sky-100 text-sky-700",
+ "bg-emerald-100 text-emerald-700",
+ "bg-amber-100 text-amber-700",
+ ];
+ const coverageIcons: IconName[] = ["cloudSun", "lineChart", "bell"];
- const trackLoginStart = (mode: "login" | "signup") => {
- trackAppEvent("login_start", {
- entry: "landing",
- mode,
- next: "/terminal",
- });
- };
-
- const trackEnterTerminal = (entry: string) => {
- trackAppEvent("enter_terminal", {
- entry,
- authenticated: isAuthenticated,
- });
- };
-
- const trackTerminalAuthStart = (entry: string, mode: "login" | "signup") => {
- trackEnterTerminal(entry);
- trackLoginStart(mode);
- };
-
- const platformCards = isEn
+ const platformCards: Array<{ body: string; icon: IconName; title: string }> = isEn
? [
{
- icon: Radar,
+ icon: "radar",
title: "Live Evidence",
body: "Airport observations, model spreads, and deviation checks stay in one calm workspace.",
},
{
- icon: Gauge,
+ icon: "gauge",
title: "Daily Review",
body: "Scan the city board, compare forecasts, and keep the current decision context visible.",
},
{
- icon: ShieldCheck,
+ icon: "shield",
title: "Access Control",
body: "Trial users get the same product experience as Pro, except the paid Telegram group link stays hidden.",
},
]
: [
{
- icon: Radar,
+ icon: "radar",
title: "实况证据",
body: "机场观测、模型分歧与偏差校验放在一个安静清晰的工作台里。",
},
{
- icon: Gauge,
+ icon: "gauge",
title: "每日复盘",
body: "快速扫描城市面板、比较预报路径,并保留当前判断上下文。",
},
{
- icon: ShieldCheck,
+ icon: "shield",
title: "权益分层",
body: "试用期权益和 Pro 一致,唯一例外是不显示付费 Telegram 群链接。",
},
@@ -169,6 +246,7 @@ function InstitutionalLandingScreen() {
return (
+
@@ -253,26 +282,7 @@ function InstitutionalLandingScreen() {
? "A calmer way to read airport weather, model forecasts, and intraday risk before the market moves."
: "用更轻松的方式阅读机场天气、模型预报和日内风险,在市场变化前完成判断。"}
-
-
- authChecked && isAuthenticated
- ? trackEnterTerminal("landing_hero")
- : trackTerminalAuthStart("landing_hero_login", "login")
- }
- className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-slate-950 px-5 text-sm font-bold text-white shadow-sm hover:bg-slate-800"
- >
- {isEn ? "Open product" : "进入产品"}
-
-
-
- {isEn ? "Subscribe / account" : "订阅 / 账户"}
-
-
+
{isEn
? "Start with a one-time 3-day trial. Trial access matches Pro except for the paid Telegram group link."
@@ -285,7 +295,9 @@ function InstitutionalLandingScreen() {
- polyweather.app/terminal
+
+ polyweather.app/terminal
+
{heroStats.map((item) => (
-
-
{item.value}
+
+
+ {item.value}
+
{item.label}
))}
@@ -320,15 +337,17 @@ function InstitutionalLandingScreen() {
{isEn ? "Platform" : "平台能力"}
- {isEn ? "Like a tidy workspace for weather decisions." : "像整理好的工作区一样阅读天气决策。"}
+ {isEn
+ ? "Like a tidy workspace for weather decisions."
+ : "像整理好的工作区一样阅读天气决策。"}
- {platformCards.map(({ body, icon: Icon, title }) => (
+ {platformCards.map(({ body, icon, title }) => (
-
+
{title}
{body}
@@ -352,15 +371,16 @@ function InstitutionalLandingScreen() {
{coverage.map((item, index) => (
-
-
- {index % 3 === 0 ? : index % 3 === 1 ? : }
+
+
+
{item}
@@ -377,7 +397,9 @@ function InstitutionalLandingScreen() {
{isEn ? "Pricing" : "定价"}
- {isEn ? "Try first, upgrade when it becomes part of your workflow." : "先试用,确认进入工作流后再开通 Pro。"}
+ {isEn
+ ? "Try first, upgrade when it becomes part of your workflow."
+ : "先试用,确认进入工作流后再开通 Pro。"}
{isEn
@@ -389,7 +411,7 @@ function InstitutionalLandingScreen() {
-
+
{isEn ? "Trial" : "试用"}
@@ -405,7 +427,7 @@ function InstitutionalLandingScreen() {
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Start trial" : "开始试用"}
-
+
@@ -414,8 +436,8 @@ function InstitutionalLandingScreen() {
{isEn ? "Popular" : "常用"}
-
- {isEn ? "Pro" : "Pro"}
+
+ Pro
{isEn ? "Pro Monthly" : "Pro 月付"}
@@ -435,7 +457,11 @@ function InstitutionalLandingScreen() {
{(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => (
-
+
{feature}
))}
@@ -445,13 +471,13 @@ function InstitutionalLandingScreen() {
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md bg-slate-950 text-sm font-bold text-white hover:bg-slate-800"
>
{isEn ? "Subscribe monthly" : "订阅月付 Pro"}
-
+
-
+
{isEn ? "Quarterly" : "季度"}
@@ -476,7 +502,7 @@ function InstitutionalLandingScreen() {
className="mt-8 inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
>
{isEn ? "Choose quarterly" : "选择季度 Pro"}
-
+
@@ -487,10 +513,7 @@ function InstitutionalLandingScreen() {
);
}
-export function InstitutionalLandingPage() {
- return (
-
-
-
- );
+export async function InstitutionalLandingPage() {
+ const locale = await resolveLandingLocale();
+ return
;
}
diff --git a/frontend/components/landing/LandingAnalytics.tsx b/frontend/components/landing/LandingAnalytics.tsx
new file mode 100644
index 00000000..34bcca5b
--- /dev/null
+++ b/frontend/components/landing/LandingAnalytics.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect } from "react";
+
+export function LandingAnalytics() {
+ useEffect(() => {
+ let active = true;
+ void import("@/lib/app-analytics").then(({ markAnalyticsOnce, trackAppEvent }) => {
+ if (!active) return;
+ if (markAnalyticsOnce("landing_view", "session")) {
+ trackAppEvent("landing_view", { entry: "landing" });
+ }
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return null;
+}
diff --git a/frontend/components/landing/LandingAuthActions.tsx b/frontend/components/landing/LandingAuthActions.tsx
new file mode 100644
index 00000000..e75c4549
--- /dev/null
+++ b/frontend/components/landing/LandingAuthActions.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
+import type { LandingLocale } from "@/components/landing/landingLocale";
+
+let authStatePromise: Promise
| null = null;
+
+function getLandingAuthState() {
+ if (!authStatePromise) {
+ authStatePromise = Promise.resolve()
+ .then(async () => {
+ const { getSupabaseBrowserClient, hasSupabasePublicEnv } = await import(
+ "@/lib/supabase/client"
+ );
+ if (!hasSupabasePublicEnv()) return false;
+ const supabase = getSupabaseBrowserClient();
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+ return !!session?.user;
+ })
+ .catch(() => false);
+ }
+ return authStatePromise;
+}
+
+function useLandingAuthState() {
+ const [state, setState] = useState({ checked: false, authenticated: false });
+
+ useEffect(() => {
+ let active = true;
+ getLandingAuthState().then((authenticated) => {
+ if (!active) return;
+ setState({ checked: true, authenticated });
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return state;
+}
+
+function ArrowIcon() {
+ return (
+
+
+
+ );
+}
+
+function UserIcon() {
+ return (
+
+
+
+ );
+}
+
+function trackLandingEvent(
+ eventType: "enter_terminal" | "login_start",
+ payload: Record,
+) {
+ void import("@/lib/app-analytics")
+ .then(({ trackAppEvent }) => {
+ trackAppEvent(eventType, payload);
+ })
+ .catch(() => {});
+}
+
+function trackLoginStart(mode: "login" | "signup") {
+ trackLandingEvent("login_start", {
+ entry: "landing",
+ mode,
+ next: "/terminal",
+ });
+}
+
+function trackEnterTerminal(entry: string, authenticated: boolean) {
+ trackLandingEvent("enter_terminal", {
+ entry,
+ authenticated,
+ });
+}
+
+function trackTerminalAuthStart(entry: string, mode: "login" | "signup") {
+ trackLandingEvent("enter_terminal", {
+ entry,
+ authenticated: false,
+ });
+ trackLoginStart(mode);
+}
+
+export function LandingHeaderActions({ locale }: { locale: LandingLocale }) {
+ const isEn = locale === "en-US";
+ const { authenticated, checked } = useLandingAuthState();
+
+ return (
+
+
+
+ {!checked ? (
+
+ ) : authenticated ? (
+
+
trackEnterTerminal("landing_header", true)}
+ className="inline-flex h-9 items-center gap-2 rounded-md bg-slate-950 px-3 text-sm font-semibold text-white hover:bg-slate-800"
+ >
+ {isEn ? "Open" : "进入"}
+
+
+
+
+
+
+ ) : (
+ <>
+
trackTerminalAuthStart("landing_header_login", "login")}
+ className="hidden h-9 items-center rounded-md px-3 text-sm font-semibold text-slate-600 hover:text-slate-950 sm:inline-flex"
+ >
+ {isEn ? "Log in" : "登录"}
+
+
trackTerminalAuthStart("landing_header_signup", "signup")}
+ className="inline-flex h-9 items-center gap-2 rounded-md bg-slate-950 px-3 text-sm font-semibold text-white hover:bg-slate-800"
+ >
+ {isEn ? "Start" : "开始使用"}
+
+
+ >
+ )}
+
+ );
+}
+
+export function LandingHeroActions({ locale }: { locale: LandingLocale }) {
+ const isEn = locale === "en-US";
+ const { authenticated, checked } = useLandingAuthState();
+ const href = checked && !authenticated ? "/auth/login?next=%2Fterminal" : "/terminal";
+
+ const handleOpenProduct = () => {
+ if (checked && !authenticated) {
+ trackTerminalAuthStart("landing_hero_login", "login");
+ return;
+ }
+ trackEnterTerminal("landing_hero", authenticated);
+ };
+
+ return (
+
+
+ {isEn ? "Open product" : "进入产品"}
+
+
+
+ {isEn ? "Subscribe / account" : "订阅 / 账户"}
+
+
+ );
+}
diff --git a/frontend/components/landing/LandingLocaleToggle.tsx b/frontend/components/landing/LandingLocaleToggle.tsx
new file mode 100644
index 00000000..154edfc9
--- /dev/null
+++ b/frontend/components/landing/LandingLocaleToggle.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import {
+ LANDING_LOCALE_COOKIE,
+ nextLandingLocale,
+ type LandingLocale,
+} from "@/components/landing/landingLocale";
+
+const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
+
+export function LandingLocaleToggle({ locale }: { locale: LandingLocale }) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const isEn = locale === "en-US";
+
+ const toggleLocale = () => {
+ const nextLocale = nextLandingLocale(locale);
+ document.cookie = `${LANDING_LOCALE_COOKIE}=${nextLocale}; Max-Age=${ONE_YEAR_SECONDS}; Path=/; SameSite=Lax`;
+ document.documentElement.lang = nextLocale;
+ try {
+ window.localStorage.setItem(LANDING_LOCALE_COOKIE, nextLocale);
+ } catch {
+ // Locale persistence is best-effort; the cookie is enough for SSR.
+ }
+ startTransition(() => {
+ router.refresh();
+ });
+ };
+
+ return (
+
+
+ 中
+
+
+ EN
+
+
+ );
+}
diff --git a/frontend/components/landing/PreloadTerminalData.tsx b/frontend/components/landing/PreloadTerminalData.tsx
deleted file mode 100644
index 5ae8c678..00000000
--- a/frontend/components/landing/PreloadTerminalData.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-
-/**
- * Warms the backend cache for public API endpoints while the user reads
- * the landing page, so the first terminal load after login is faster.
- *
- * - /api/cities (public, triggers Python process warmup and connection pool init)
- * - scan terminal (protected — skipped here; prefetched by the terminal
- * component's stagger loading after login)
- */
-export function PreloadTerminalData() {
- useEffect(() => {
- if (typeof window === "undefined" || typeof fetch !== "function") return;
-
- const controller = new AbortController();
-
- // Delay 2s so the landing page critical assets load first
- const t = setTimeout(
- () =>
- fetch("/api/cities", {
- cache: "no-store",
- headers: { Accept: "application/json" },
- signal: controller.signal,
- }).catch(() => {}),
- 2000,
- );
-
- return () => {
- controller.abort();
- clearTimeout(t);
- };
- }, []);
-
- return null;
-}
diff --git a/frontend/public/static/web.png b/frontend/components/landing/__tests__/fixtures/web.png
similarity index 100%
rename from frontend/public/static/web.png
rename to frontend/components/landing/__tests__/fixtures/web.png
diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts
index b890cb15..686584ad 100644
--- a/frontend/components/landing/__tests__/landingPricingReferral.test.ts
+++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts
@@ -11,18 +11,49 @@ export function runTests() {
path.join(root, "components", "landing", "InstitutionalLandingPage.tsx"),
"utf8",
);
+ const authActionsSource = fs.readFileSync(
+ path.join(root, "components", "landing", "LandingAuthActions.tsx"),
+ "utf8",
+ );
+ const analyticsSource = fs.readFileSync(
+ path.join(root, "components", "landing", "LandingAnalytics.tsx"),
+ "utf8",
+ );
+ const localeToggleSource = fs.readFileSync(
+ path.join(root, "components", "landing", "LandingLocaleToggle.tsx"),
+ "utf8",
+ );
const appPageSource = fs.readFileSync(path.join(root, "app", "page.tsx"), "utf8");
- const pngPath = path.join(root, "public", "static", "web.png");
+ const publicPngPath = path.join(root, "public", "static", "web.png");
+ const fixturePngPath = path.join(root, "components", "landing", "__tests__", "fixtures", "web.png");
const webpPath = path.join(root, "public", "static", "web.webp");
+ assert(!source.startsWith('"use client"'), "landing body must be a Server Component");
+ assert(!source.includes("@/lib/supabase/client"), "landing body must not import the Supabase browser client");
+ assert(!source.includes("useEffect") && !source.includes("useState"), "landing body must not hydrate static content with React hooks");
+ assert(!source.includes("lucide-react"), "landing body must not import lucide-react for the LCP route");
+ assert(authActionsSource.startsWith('"use client"'), "auth actions must be isolated in a client island");
+ assert(analyticsSource.startsWith('"use client"'), "analytics must be isolated in a client island");
+ assert(localeToggleSource.startsWith('"use client"'), "locale toggle must be isolated in a client island");
+ assert(!authActionsSource.includes('from "@/lib/supabase/client"'), "auth island must not eagerly import the Supabase browser client");
+ assert(
+ authActionsSource.includes("await import(") && authActionsSource.includes('"@/lib/supabase/client"'),
+ "auth island must lazy-load the Supabase browser client after hydration",
+ );
+ assert(!analyticsSource.includes('from "@/lib/app-analytics"'), "analytics island must lazy-load analytics code");
+ assert(!authActionsSource.includes("lucide-react"), "auth island must avoid shipping lucide-react");
+ assert(!localeToggleSource.includes("lucide-react"), "locale island must avoid shipping lucide-react");
+ assert(!analyticsSource.includes("lucide-react"), "analytics island must avoid shipping lucide-react");
assert(source.includes("3 天免费试用"), "landing page must advertise the 3-day trial");
assert(source.includes("试用期权益和 Pro 一致,除了不显示付费 Telegram 群链接"), "landing page must state trial access matches Pro except the paid group link");
assert(!source.includes("高频刷新与 API 仍为 Pro 权益"), "landing page must not incorrectly exclude high-frequency refresh or API from trial access");
assert(source.includes("bg-[#fbfbfa]"), "landing page must use a light Notion-style background");
assert(source.includes("WeatherWorkflowIllustration"), "landing page must include a friendly illustration surface");
+ assert(!fs.existsSync(publicPngPath), "heavy PNG preview must not remain in public static assets");
+ assert(fs.existsSync(fixturePngPath), "PNG preview may only remain as a test fixture");
assert(fs.existsSync(webpPath), "landing page must ship a WebP preview image for the LCP product screenshot");
assert(
- fs.statSync(webpPath).size < fs.statSync(pngPath).size * 0.65,
+ fs.statSync(webpPath).size < fs.statSync(fixturePngPath).size * 0.65,
"WebP preview must be materially smaller than the PNG LCP image",
);
assert(source.includes("/static/web.webp"), "landing page must load the lighter WebP product preview image");
@@ -35,10 +66,10 @@ export function runTests() {
"landing product preview must expose stable intrinsic dimensions and high fetch priority",
);
assert(
- source.includes('trackAppEvent("landing_view"') &&
- source.includes('trackAppEvent("login_start"') &&
- source.includes('trackAppEvent("enter_terminal"'),
- "landing page must emit the top-of-funnel analytics events",
+ analyticsSource.includes('"landing_view"') &&
+ authActionsSource.includes('"login_start"') &&
+ authActionsSource.includes('"enter_terminal"'),
+ "landing client islands must emit the top-of-funnel analytics events",
);
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
@@ -49,6 +80,7 @@ export function runTests() {
assert(appPageSource.includes('price: "29.90"'), "JSON-LD must expose monthly Pro pricing");
assert(appPageSource.includes('price: "79.90"'), "JSON-LD must expose quarterly Pro pricing");
assert(!appPageSource.includes('price: "10.00"'), "legacy JSON-LD pricing must be removed");
+ assert(!appPageSource.includes("PreloadTerminalData"), "landing route must not add a fourth client island");
}
function projectRoot() {
diff --git a/frontend/components/landing/landingLocale.ts b/frontend/components/landing/landingLocale.ts
new file mode 100644
index 00000000..da493571
--- /dev/null
+++ b/frontend/components/landing/landingLocale.ts
@@ -0,0 +1,35 @@
+export type LandingLocale = "zh-CN" | "en-US";
+
+export const LANDING_LOCALE_COOKIE = "polyweather.locale";
+export const DEFAULT_LANDING_LOCALE: LandingLocale = "zh-CN";
+
+export function normalizeLandingLocale(value: string | null | undefined): LandingLocale | null {
+ if (!value) return null;
+ const normalized = value.trim().toLowerCase();
+ if (normalized === "zh" || normalized === "zh-cn" || normalized.startsWith("zh-")) {
+ return "zh-CN";
+ }
+ if (normalized === "en" || normalized === "en-us" || normalized.startsWith("en-")) {
+ return "en-US";
+ }
+ return null;
+}
+
+export function pickLandingLocale(
+ cookieLocale: string | null | undefined,
+ acceptLanguage: string | null | undefined,
+): LandingLocale {
+ const fromCookie = normalizeLandingLocale(cookieLocale);
+ if (fromCookie) return fromCookie;
+
+ for (const part of String(acceptLanguage || "").split(",")) {
+ const locale = normalizeLandingLocale(part.split(";")[0]);
+ if (locale) return locale;
+ }
+
+ return DEFAULT_LANDING_LOCALE;
+}
+
+export function nextLandingLocale(locale: LandingLocale): LandingLocale {
+ return locale === "zh-CN" ? "en-US" : "zh-CN";
+}