Files
PolyWeather/frontend/components/dashboard/HeaderBar.tsx
T
2569718930@qq.com 60093d3162 @
统一月付价格为 10 USDC 并清理所有 trial 文案

- 默认月付 fallback 从 5U 改为 10U(contract_checkout.py、AccountCenter.tsx)
- 移除 LoginClient / HeaderBar / AccountCenter / UnlockProOverlay 中的试用推广文案
- VPS 同步: PLAN_CATALOG_JSON、GROUP_MEMBER_PRICE_USDC 更新为 10
- SIGNUP_TRIAL_ENABLED=false 保持关闭
@
2026-05-20 11:12:29 +08:00

228 lines
6.8 KiB
TypeScript

"use client";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import {
LogIn,
UserRound,
RotateCw,
BookOpen,
MoreHorizontal,
Moon,
Sun,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useDashboardStore, useProAccess } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
function parseExpiryInfo(raw?: string | null) {
const text = String(raw || "").trim();
if (!text) return null;
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return null;
const diffMs = dt.getTime() - Date.now();
const daysLeft = Math.ceil(diffMs / 86_400_000);
return {
date: dt,
daysLeft,
expired: diffMs <= 0,
};
}
export function HeaderBar({
refreshAction,
refreshSpinning,
}: {
refreshAction?: () => void | Promise<void>;
refreshSpinning?: boolean;
}) {
const store = useDashboardStore();
const { proAccess } = useProAccess();
const { locale, t, toggleLocale } = useI18n();
const pathname = usePathname();
const [theme, setTheme] = useState<"dark" | "light">("dark");
const isAuthenticated = proAccess.authenticated;
const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs");
const navItems = [
{
href: "/",
label: locale === "en-US" ? "Dashboard" : "总览",
active: pathname === "/",
},
];
const isRefreshing = refreshSpinning ?? store.loadingState.refresh;
const handleRefresh = () => {
if (refreshAction) {
return void refreshAction();
}
return void store.refreshAll();
};
const accountHref = isAuthenticated
? "/account"
: "/auth/login?next=%2Faccount";
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
const effectiveExpiry = proAccess.subscriptionActive
? proAccess.subscriptionTotalExpiresAt ||
proAccess.subscriptionExpiresAt
: proAccess.subscriptionExpiresAt;
const expiryInfo = parseExpiryInfo(effectiveExpiry);
const hasQueuedExtension = Boolean(
proAccess.subscriptionActive &&
proAccess.subscriptionQueuedDays > 0,
);
const showRenewReminder =
isAuthenticated &&
!proAccess.loading &&
!hasQueuedExtension &&
((proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!proAccess.subscriptionActive && Boolean(expiryInfo)));
const renewReminderLabel = !showRenewReminder
? ""
: !proAccess.subscriptionActive
? locale === "en-US"
? "Pro expired"
: "Pro 已到期"
: locale === "en-US"
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)} 天`;
useEffect(() => {
const savedTheme =
typeof window !== "undefined"
? window.localStorage.getItem("polyweather_theme")
: null;
const nextTheme = savedTheme === "light" ? "light" : "dark";
setTheme(nextTheme);
document.documentElement.classList.toggle("dark", nextTheme === "dark");
document.documentElement.classList.toggle("light", nextTheme === "light");
}, []);
const toggleTheme = () => {
const nextTheme = theme === "dark" ? "light" : "dark";
setTheme(nextTheme);
document.documentElement.classList.toggle("dark", nextTheme === "dark");
document.documentElement.classList.toggle("light", nextTheme === "light");
window.localStorage.setItem("polyweather_theme", nextTheme);
};
return (
<header className="header">
<div className="brand">
<span className="brand-mark" aria-hidden="true">
<Image src="/favicon-32x32.png" alt="" width={24} height={24} priority />
</span>
<h1>PolyWeather</h1>
<span className="subtitle">{t("header.subtitle")}</span>
</div>
<nav
className="header-nav"
aria-label={locale === "en-US" ? "Primary" : "主导航"}
>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={clsx("header-nav-link", item.active && "active")}
>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="header-right">
<button
type="button"
className="locale-switch"
aria-label={locale === "en-US" ? "Switch to Chinese" : "切换到英文"}
title={locale === "en-US" ? "Switch to Chinese" : "切换到英文"}
onClick={toggleLocale}
>
<span className={clsx(locale === "zh-CN" && "active")}>中文</span>
<span className={clsx(locale === "en-US" && "active")}>EN</span>
</button>
<div className="live-badge" id="liveBadge">
<span className="pulse-dot" />
<span>{t("header.live")}</span>
</div>
<button
type="button"
className={clsx("refresh-btn", isRefreshing && "spinning")}
title={t("header.refreshAria")}
aria-label={t("header.refreshAria")}
onClick={handleRefresh}
>
<RotateCw size={16} strokeWidth={2} />
</button>
<button
type="button"
className="header-utility-btn"
aria-label={theme === "dark" ? "切换到明亮模式" : "切换到暗黑模式"}
title={theme === "dark" ? "明亮模式" : "暗黑模式"}
onClick={toggleTheme}
>
{theme === "dark" ? (
<Sun size={15} strokeWidth={2} />
) : (
<Moon size={15} strokeWidth={2} />
)}
</button>
<Link
href={docsHref}
className={clsx("header-utility-btn", docsActive && "active")}
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
<span>{t("header.docs")}</span>
</Link>
<Link
href={accountHref}
className="header-utility-btn"
title={accountAria}
aria-label={accountAria}
>
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
</Link>
<button
type="button"
className="header-utility-btn more"
aria-label={locale === "en-US" ? "More actions" : "更多操作"}
title={locale === "en-US" ? "More actions" : "更多操作"}
>
<MoreHorizontal size={16} strokeWidth={2} />
</button>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
</div>
</header>
);
}