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 (
+
@@ -187,56 +265,7 @@ function InstitutionalLandingScreen() { -
- - - {!authChecked ? ( -
- ) : isAuthenticated ? ( -
- trackEnterTerminal("landing_header")} - 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" : "开始使用"} - - - - )} -
+
@@ -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 ( + + ); +} 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"; +}