Optimize landing server rendering
This commit is contained in:
@@ -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) }}
|
||||
/>
|
||||
<PreloadTerminalData />
|
||||
<InstitutionalLandingPage />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<svg {...common}>
|
||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||
<path d="M12 7a5 5 0 1 0 5 5" />
|
||||
<path d="M12 12l7-7" />
|
||||
<path d="M16 4h4v4" />
|
||||
</svg>
|
||||
);
|
||||
case "gauge":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 14a8 8 0 1 1 16 0" />
|
||||
<path d="M12 14l4-4" />
|
||||
<path d="M8 18h8" />
|
||||
</svg>
|
||||
);
|
||||
case "shield":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M12 3l7 3v5c0 4.3-2.8 8.1-7 9-4.2-.9-7-4.7-7-9V6l7-3Z" />
|
||||
<path d="M9 12l2 2 4-5" />
|
||||
</svg>
|
||||
);
|
||||
case "cloudSun":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M8 14.5a4 4 0 0 1 7.8-1.2A3.3 3.3 0 1 1 17 20H8.5a2.8 2.8 0 0 1-.5-5.5Z" />
|
||||
<path d="M16 3v2" />
|
||||
<path d="M20.2 4.8l-1.4 1.4" />
|
||||
<path d="M21 10h-2" />
|
||||
<path d="M12 4.8l1.4 1.4" />
|
||||
</svg>
|
||||
);
|
||||
case "lineChart":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="M7 15l3-4 3 2 4-6" />
|
||||
</svg>
|
||||
);
|
||||
case "bell":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M6 9a6 6 0 0 1 12 0c0 6 2 6 2 8H4c0-2 2-2 2-8Z" />
|
||||
<path d="M10 20a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
);
|
||||
case "database":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<ellipse cx="12" cy="6" rx="7" ry="3" />
|
||||
<path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6" />
|
||||
<path d="M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
|
||||
</svg>
|
||||
);
|
||||
case "check":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 12.5l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
case "arrow":
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function WeatherWorkflowIllustration() {
|
||||
return (
|
||||
<div
|
||||
@@ -71,83 +176,55 @@ function WeatherWorkflowIllustration() {
|
||||
);
|
||||
}
|
||||
|
||||
function InstitutionalLandingScreen() {
|
||||
const { locale, toggleLocale } = useI18n();
|
||||
async function resolveLandingLocale(): Promise<LandingLocale> {
|
||||
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 (
|
||||
<div className="min-h-screen bg-[#fbfbfa] text-slate-950 antialiased">
|
||||
<LandingAnalytics />
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-[#fbfbfa]/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
|
||||
<Link href="/" className="flex items-center gap-2 transition-opacity hover:opacity-80">
|
||||
@@ -187,56 +265,7 @@ function InstitutionalLandingScreen() {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocale}
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-1 rounded-md border border-slate-200 bg-white px-1 text-xs font-semibold text-slate-500 shadow-sm hover:border-slate-300"
|
||||
>
|
||||
<span className={`rounded px-2 py-1 ${!isEn ? "bg-slate-900 text-white" : ""}`}>中</span>
|
||||
<span className={`rounded px-2 py-1 ${isEn ? "bg-slate-900 text-white" : ""}`}>EN</span>
|
||||
</button>
|
||||
|
||||
{!authChecked ? (
|
||||
<div className="h-9 w-24 animate-pulse rounded-md bg-slate-200" />
|
||||
) : isAuthenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/terminal"
|
||||
onClick={() => 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" : "进入"}
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
<Link
|
||||
href="/account"
|
||||
className="grid h-9 w-9 place-items-center rounded-md border border-slate-200 bg-white text-slate-600 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
title={isEn ? "Account" : "账户"}
|
||||
>
|
||||
<Users size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/auth/login?next=%2Fterminal"
|
||||
onClick={() => 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" : "登录"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/login?next=%2Fterminal&mode=signup"
|
||||
onClick={() => 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" : "开始使用"}
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<LandingHeaderActions locale={locale} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -253,26 +282,7 @@ function InstitutionalLandingScreen() {
|
||||
? "A calmer way to read airport weather, model forecasts, and intraday risk before the market moves."
|
||||
: "用更轻松的方式阅读机场天气、模型预报和日内风险,在市场变化前完成判断。"}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={authChecked && isAuthenticated ? "/terminal" : "/auth/login?next=%2Fterminal"}
|
||||
onClick={() =>
|
||||
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" : "进入产品"}
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
<Link
|
||||
href="/account"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-5 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
{isEn ? "Subscribe / account" : "订阅 / 账户"}
|
||||
</Link>
|
||||
</div>
|
||||
<LandingHeroActions locale={locale} />
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
{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() {
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ff6b6b]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#ffd166]" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
|
||||
<span className="ml-2 text-xs font-semibold text-slate-400">polyweather.app/terminal</span>
|
||||
<span className="ml-2 text-xs font-semibold text-slate-400">
|
||||
polyweather.app/terminal
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 aspect-[16/9] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
|
||||
<img
|
||||
@@ -304,8 +316,13 @@ function InstitutionalLandingScreen() {
|
||||
|
||||
<div className="mx-auto mt-8 grid max-w-5xl gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{heroStats.map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-slate-200 bg-white px-4 py-4 shadow-sm">
|
||||
<div className="font-mono text-lg font-black text-slate-950">{item.value}</div>
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-slate-200 bg-white px-4 py-4 shadow-sm"
|
||||
>
|
||||
<div className="font-mono text-lg font-black text-slate-950">
|
||||
{item.value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-medium text-slate-500">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -320,15 +337,17 @@ function InstitutionalLandingScreen() {
|
||||
{isEn ? "Platform" : "平台能力"}
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
|
||||
{isEn ? "Like a tidy workspace for weather decisions." : "像整理好的工作区一样阅读天气决策。"}
|
||||
{isEn
|
||||
? "Like a tidy workspace for weather decisions."
|
||||
: "像整理好的工作区一样阅读天气决策。"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-3">
|
||||
{platformCards.map(({ body, icon: Icon, title }) => (
|
||||
{platformCards.map(({ body, icon, title }) => (
|
||||
<article key={title} className="rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 bg-white text-slate-800">
|
||||
<Icon size={19} />
|
||||
<LandingIcon name={icon} size={19} />
|
||||
</div>
|
||||
<h3 className="text-lg font-black text-slate-950">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{body}</p>
|
||||
@@ -352,15 +371,16 @@ function InstitutionalLandingScreen() {
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{coverage.map((item, index) => (
|
||||
<div key={item} className="flex items-center gap-3 rounded-md border border-slate-100 bg-[#fbfbfa] px-4 py-3">
|
||||
<span className={`grid h-8 w-8 place-items-center rounded-md ${
|
||||
index % 3 === 0
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: index % 3 === 1
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{index % 3 === 0 ? <CloudSun size={16} /> : index % 3 === 1 ? <LineChart size={16} /> : <Bell size={16} />}
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-3 rounded-md border border-slate-100 bg-[#fbfbfa] px-4 py-3"
|
||||
>
|
||||
<span
|
||||
className={`grid h-8 w-8 place-items-center rounded-md ${
|
||||
coverageAccentClasses[index % coverageAccentClasses.length]
|
||||
}`}
|
||||
>
|
||||
<LandingIcon name={coverageIcons[index % coverageIcons.length]} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-slate-700">{item}</span>
|
||||
</div>
|
||||
@@ -377,7 +397,9 @@ function InstitutionalLandingScreen() {
|
||||
{isEn ? "Pricing" : "定价"}
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-slate-950 sm:text-4xl">
|
||||
{isEn ? "Try first, upgrade when it becomes part of your workflow." : "先试用,确认进入工作流后再开通 Pro。"}
|
||||
{isEn
|
||||
? "Try first, upgrade when it becomes part of your workflow."
|
||||
: "先试用,确认进入工作流后再开通 Pro。"}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
||||
{isEn
|
||||
@@ -389,7 +411,7 @@ function InstitutionalLandingScreen() {
|
||||
<div className="mt-12 grid gap-4 md:grid-cols-3">
|
||||
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-emerald-700">
|
||||
<Clock3 size={16} />
|
||||
<LandingIcon name="clock" />
|
||||
{isEn ? "Trial" : "试用"}
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-black text-slate-950">
|
||||
@@ -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" : "开始试用"}
|
||||
<ArrowRight size={15} />
|
||||
<LandingIcon name="arrow" size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -414,8 +436,8 @@ function InstitutionalLandingScreen() {
|
||||
{isEn ? "Popular" : "常用"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-sky-700">
|
||||
<Database size={16} />
|
||||
{isEn ? "Pro" : "Pro"}
|
||||
<LandingIcon name="database" />
|
||||
Pro
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-black text-slate-950">
|
||||
{isEn ? "Pro Monthly" : "Pro 月付"}
|
||||
@@ -435,7 +457,11 @@ function InstitutionalLandingScreen() {
|
||||
<ul className="mt-7 space-y-3 border-t border-slate-200 pt-6">
|
||||
{(isEn ? PRO_FEATURES_EN : PRO_FEATURES_ZH).map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<Check size={15} className="mt-0.5 shrink-0 text-slate-500" />
|
||||
<LandingIcon
|
||||
name="check"
|
||||
size={15}
|
||||
className="mt-0.5 shrink-0 text-slate-500"
|
||||
/>
|
||||
<span className="text-sm leading-6 text-slate-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -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"}
|
||||
<ArrowRight size={15} />
|
||||
<LandingIcon name="arrow" size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-lg border border-slate-200 bg-[#fbfbfa] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-amber-700">
|
||||
<LineChart size={16} />
|
||||
<LandingIcon name="lineChart" />
|
||||
{isEn ? "Quarterly" : "季度"}
|
||||
</div>
|
||||
<h3 className="mt-5 text-2xl font-black text-slate-950">
|
||||
@@ -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"}
|
||||
<ArrowRight size={15} />
|
||||
<LandingIcon name="arrow" size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,10 +513,7 @@ function InstitutionalLandingScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
export function InstitutionalLandingPage() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<InstitutionalLandingScreen />
|
||||
</I18nProvider>
|
||||
);
|
||||
export async function InstitutionalLandingPage() {
|
||||
const locale = await resolveLandingLocale();
|
||||
return <InstitutionalLandingScreen locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean> | 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 (
|
||||
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none">
|
||||
<path
|
||||
d="M4 10h11m-4.5-4.5L15 10l-4.5 4.5"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-4 w-4" fill="none">
|
||||
<path
|
||||
d="M10 10a3.2 3.2 0 1 0 0-6.4A3.2 3.2 0 0 0 10 10Zm-5.5 6.4c.7-2.5 2.7-4 5.5-4s4.8 1.5 5.5 4"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function trackLandingEvent(
|
||||
eventType: "enter_terminal" | "login_start",
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<LandingLocaleToggle locale={locale} />
|
||||
|
||||
{!checked ? (
|
||||
<div className="h-9 w-24 animate-pulse rounded-md bg-slate-200" />
|
||||
) : authenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/terminal"
|
||||
onClick={() => 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" : "进入"}
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
<Link
|
||||
href="/account"
|
||||
className="grid h-9 w-9 place-items-center rounded-md border border-slate-200 bg-white text-slate-600 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
title={isEn ? "Account" : "账户"}
|
||||
aria-label={isEn ? "Account" : "账户"}
|
||||
>
|
||||
<UserIcon />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/auth/login?next=%2Fterminal"
|
||||
onClick={() => 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" : "登录"}
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/login?next=%2Fterminal&mode=signup"
|
||||
onClick={() => 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" : "开始使用"}
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={href}
|
||||
onClick={handleOpenProduct}
|
||||
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" : "进入产品"}
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
<Link
|
||||
href="/account"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-5 text-sm font-bold text-slate-700 shadow-sm hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
{isEn ? "Subscribe / account" : "订阅 / 账户"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocale}
|
||||
disabled={isPending}
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-1 rounded-md border border-slate-200 bg-white px-1 text-xs font-semibold text-slate-500 shadow-sm hover:border-slate-300 disabled:cursor-wait disabled:opacity-70"
|
||||
aria-label={isEn ? "Switch language" : "切换语言"}
|
||||
>
|
||||
<span className={`rounded px-2 py-1 ${!isEn ? "bg-slate-900 text-white" : ""}`}>
|
||||
中
|
||||
</span>
|
||||
<span className={`rounded px-2 py-1 ${isEn ? "bg-slate-900 text-white" : ""}`}>
|
||||
EN
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 237 KiB |
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user