Files
PolyWeather/frontend/components/dashboard/HeaderBar.tsx
T
2026-04-22 03:25:40 +08:00

219 lines
6.4 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import {
LogIn,
UserRound,
RotateCw,
BookOpen,
Sparkles,
BarChart3,
House,
} from "lucide-react";
import { useDashboardStore } 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 { locale, setLocale, t } = useI18n();
const pathname = usePathname();
const isAuthenticated = store.proAccess.authenticated;
const docsHref = "/docs/intro";
const docsActive = pathname?.startsWith("/docs");
const homeHref = "/";
const homeActive = pathname === "/";
const probabilityHubHref = "/probabilities";
const probabilityHubActive = pathname?.startsWith("/probabilities");
const trialPromoLabel =
locale === "en-US"
? "New users get 3-day Pro trial"
: "新用户可免费体验 3 天 Pro";
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 accountLabel = isAuthenticated
? t("header.account")
: t("header.signIn");
const accountAria = isAuthenticated
? t("header.accountAria")
: t("header.signInAria");
const effectiveExpiry = store.proAccess.subscriptionActive
? store.proAccess.subscriptionTotalExpiresAt ||
store.proAccess.subscriptionExpiresAt
: store.proAccess.subscriptionExpiresAt;
const expiryInfo = parseExpiryInfo(effectiveExpiry);
const hasQueuedExtension = Boolean(
store.proAccess.subscriptionActive &&
store.proAccess.subscriptionQueuedDays > 0,
);
const isTrialPlan = /trial/i.test(
String(store.proAccess.subscriptionPlanCode || ""),
);
const showRenewReminder =
isAuthenticated &&
!store.proAccess.loading &&
!hasQueuedExtension &&
((store.proAccess.subscriptionActive &&
expiryInfo &&
expiryInfo.daysLeft <= 3) ||
(!store.proAccess.subscriptionActive && Boolean(expiryInfo)));
const renewReminderLabel = !showRenewReminder
? ""
: !store.proAccess.subscriptionActive
? isTrialPlan
? locale === "en-US"
? "Trial ended"
: "试用已结束"
: locale === "en-US"
? "Pro expired"
: "Pro 已到期"
: isTrialPlan
? locale === "en-US"
? `Trial ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `试用剩余 ${Math.max(expiryInfo?.daysLeft || 0, 0)} 天`
: locale === "en-US"
? `Pro ${Math.max(expiryInfo?.daysLeft || 0, 0)}d left`
: `Pro 还剩 ${Math.max(expiryInfo?.daysLeft || 0, 0)} 天`;
return (
<header className="header">
<div className="brand">
<h1>PolyWeather</h1>
<span className="subtitle">{t("header.subtitle")}</span>
</div>
<div className="header-right">
<div
className="lang-switch"
role="group"
aria-label={t("header.langAria")}
>
<button
type="button"
className={clsx("lang-btn", locale === "zh-CN" && "active")}
onClick={() => setLocale("zh-CN")}
>
{t("header.langZh")}
</button>
<button
type="button"
className={clsx("lang-btn", locale === "en-US" && "active")}
onClick={() => setLocale("en-US")}
>
{t("header.langEn")}
</button>
</div>
<Link
href={homeHref}
className={clsx("info-btn", homeActive && "active")}
title={t("header.homeAria")}
aria-label={t("header.homeAria")}
>
<House size={14} strokeWidth={2} />
{t("header.home")}
</Link>
<Link
href={docsHref}
className={clsx("info-btn", docsActive && "active")}
title={t("header.docsAria")}
aria-label={t("header.docsAria")}
>
<BookOpen size={14} strokeWidth={2} />
{t("header.docs")}
</Link>
<Link
href={probabilityHubHref}
className={clsx("info-btn", probabilityHubActive && "active")}
title={t("header.probabilityHubAria")}
aria-label={t("header.probabilityHubAria")}
>
<BarChart3 size={14} strokeWidth={2} />
{t("header.probabilityHub")}
</Link>
<Link
href="/account"
className="trial-promo-badge"
title={trialPromoLabel}
aria-label={trialPromoLabel}
>
<Sparkles size={12} strokeWidth={2} />
<span>{trialPromoLabel}</span>
</Link>
<Link
href={accountHref}
className="account-btn"
title={accountAria}
aria-label={accountAria}
>
{isAuthenticated ? <UserRound size={14} /> : <LogIn size={14} />}
<span>{accountLabel}</span>
</Link>
{showRenewReminder ? (
<Link
href="/account"
className={clsx(
"account-renew-badge",
!store.proAccess.subscriptionActive && "expired",
)}
title={renewReminderLabel}
aria-label={renewReminderLabel}
>
<span>{renewReminderLabel}</span>
</Link>
) : null}
<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>
</div>
</header>
);
}