2025-09-30 16:07:26 +08:00

525 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { Menu, Rocket, LogOut, User as UserIcon, X, Info, CalendarDays } from 'lucide-react';
import { Drawer } from 'antd';
import { fetchTabsByCode, HomeTabItem } from '@/api/serversetting';
import { getSigninStatus } from '@/api/signin';
import { getCurrentUser, isAuthenticated, logoutUser } from '@/lib/auth';
import { getUserSubscriptionInfo } from '@/lib/stripe';
import { trackEvent } from '@/utils/analytics';
import { GradientText } from '@/components/ui/gradient-text';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import SigninBox from './signin-box';
import { navigationItems } from './type';
import './H5TopBar.css';
interface H5TopBarProps {
/** 点击首页 tab 时的回调,用于页面内滚动 */
onSelectHomeTab?: (key: string) => void;
}
interface CurrentUserMinimal {
id?: string;
name?: string;
email?: string;
username?: string;
}
/**
* 移动端顶栏(抽屉式菜单)
* - 未登录:左 LOGO → '/',右侧 Signup + 菜单;抽屉显示 homeTabs 与 登录/注册
* - 已登录:左 LOGO → '/movies',右侧 升级图标 + 菜单;抽屉显示 用户卡片、快捷充值、入口与登出
*/
export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
const router = useRouter();
const pathname = usePathname();
const [isLogin, setIsLogin] = useState<boolean>(false);
const [user, setUser] = useState<CurrentUserMinimal | null>(null);
const [credits, setCredits] = useState<number>(0);
const [isBuyingTokens, setIsBuyingTokens] = useState<boolean>(false);
const [homeTabs, setHomeTabs] = useState<HomeTabItem[]>([]);
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const [customAmount, setCustomAmount] = useState<string>("");
const [isSigninModalOpen, setIsSigninModalOpen] = useState(false);
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] = useState<string>("");
const [planName, setPlanName] = useState<string>("");
const [needsSigninBadge, setNeedsSigninBadge] = useState<boolean>(false);
const [isGlassActive, setIsGlassActive] = useState<boolean>(false);
const isHome = useMemo(() => pathname === '/', [pathname]);
useEffect(() => {
setIsLogin(isAuthenticated());
const u = getCurrentUser();
setUser(u || null);
}, []);
useEffect(() => {
if (!user?.id) return;
let ignore = false;
const load = async () => {
try {
const res = await getUserSubscriptionInfo(String(user.id));
if (!ignore && res?.data?.credits !== undefined) {
setCredits(res.data.credits);
if (typeof res.data.subscription_status === 'string') {
setSubscriptionStatus(res.data.subscription_status);
}
if (typeof res.data.plan_name === 'string') {
setPlanName(res.data.plan_name);
}
}
} catch {}
};
load();
return () => {
ignore = true;
};
}, [user?.id]);
// 仅首页时加载 homeTabs用于未登录抽屉导航
useEffect(() => {
if (!isHome) return;
let mounted = true;
const loadTabs = async () => {
try {
const tabs = await fetchTabsByCode('homeTab');
if (mounted && Array.isArray(tabs)) setHomeTabs(tabs);
} catch {}
};
loadTabs();
return () => {
mounted = false;
};
}, [isHome]);
// 获取今日签到状态,未签到则显示红点
useEffect(() => {
const loadSignin = async () => {
if (!isLogin) return;
try {
const data: any = await getSigninStatus();
const hasSignin = !!data?.data?.has_signin;
setNeedsSigninBadge(!hasSignin);
} catch {}
};
loadSignin();
}, [isLogin]);
// 首页滚动 30vh 后开启玻璃质感背景
useEffect(() => {
if (!isHome) return;
const computeAndSet = (evt?: Event) => {
const threshold = Math.max(0, window.innerHeight * 0.3);
const winY = window.scrollY || window.pageYOffset || 0;
const docElY = (document.documentElement && document.documentElement.scrollTop) || 0;
const scrollingElY = (document.scrollingElement as any)?.scrollTop || 0;
const bodyY = (document.body && (document.body as any).scrollTop) || 0;
let currentY = Math.max(winY, docElY, scrollingElY, bodyY);
const target = evt?.target as any;
if (target && typeof target.scrollTop === 'number') {
currentY = Math.max(currentY, target.scrollTop);
}
const nextActive = currentY >= threshold;
setIsGlassActive(nextActive);
};
// 初始计算一次
computeAndSet();
// 监听 window 与 document捕获阶段捕获内部滚动容器事件
window.addEventListener('scroll', computeAndSet, { passive: true });
document.addEventListener('scroll', computeAndSet, { passive: true, capture: true });
return () => {
window.removeEventListener('scroll', computeAndSet);
document.removeEventListener('scroll', computeAndSet, { capture: true } as any);
};
}, [isHome]);
// 离开首页时,移除玻璃背景
useEffect(() => {
if (!isHome) {
setIsGlassActive(false);
} else {
}
}, [isHome]);
const handleLogoClick = () => {
if (isLogin) {
router.push('/movies');
} else {
router.push('/');
}
};
const handleUpgrade = () => {
router.push('/pricing');
};
const handleBuyTokens = async (amount: number) => {
if (!user?.id) return;
setIsBuyingTokens(true);
try {
const url = `/pay-redirect?type=token&amount=${encodeURIComponent(amount)}&pkg=basic`;
window.open(url, '_blank');
window.postMessage({ type: 'waiting-payment', paymentType: 'subscription' }, '*');
} finally {
setIsBuyingTokens(false);
}
};
const handleCustomAmountBuy = async () => {
const amount = parseInt(customAmount);
if (isNaN(amount) || amount < 50) {
window.msg.warning("Minimum purchase is 50 credits.");
return;
}
await handleBuyTokens(amount);
setCustomAmount("");
};
const handleSignin = () => {
setIsSigninModalOpen(true);
};
const handleManageSubscription = async () => {
if (!user?.id) return;
setIsManagingSubscription(true);
try {
// 获取用户当前订阅信息
const response = await getUserSubscriptionInfo(String(user.id));
if (!response?.successful || !response?.data) {
throw new Error('Failed to get subscription info');
}
const currentPlan = response.data.plan_name;
const billingType = 'month'; // 默认使用月付用户可以在pricing页面切换
// 跟踪订阅管理按钮点击事件
trackEvent('subscription_manage_click', {
event_category: 'subscription',
event_label: 'manage_subscription',
custom_parameters: {
current_plan: currentPlan,
billing_type: billingType,
},
});
// 复用pricing页面的跳转方案构建pay-redirect URL
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(currentPlan)}&billing=${encodeURIComponent(billingType)}`;
const win = window.open(url, '_blank');
// 通知当前窗口等待支付显示loading模态框
window.postMessage({
type: 'waiting-payment',
paymentType: 'subscription',
}, '*');
if (!win) {
throw new Error('Unable to open redirect window, please check popup settings');
}
} catch (error) {
console.error('Failed to manage subscription:', error);
// 如果出错回退到pricing页面
router.push('/pricing');
} finally {
setIsManagingSubscription(false);
}
};
return (
<>
<span className={`backdrop_backdrop ${isHome && isGlassActive ? 'grid' : 'hidden'}`}>
<span className='backdrop_blur'></span>
<span className='backdrop_blur'></span>
<span className='backdrop_blur'></span>
<span className='backdrop_blur'></span>
<span className='backdrop_blur'></span>
<span className='backdrop_blur'></span>
</span>
<div data-alt="h5-topbar" className={`fixed left-0 right-0 top-0 h-16 header z-[60] ${drawerOpen ? 'bg-[#0b0b0b] pointer-events-auto' : ''}` }>
<div data-alt="bar" className="h-14 px-3 flex items-center justify-between">
{/* 左侧 LOGO */}
<div
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
onClick={handleLogoClick}
>
<h1 className="logo text-2xl font-bold">
<GradientText
text="MovieFlow"
startPercentage={30}
endPercentage={70}
/>
</h1>
{/* beta标签 */}
<div className="relative transform translate-y-[-1px]">
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-[rgb(212 202 202)] border border-[rgba(106,244,249,0.2)] rounded-full shadow-[0_0_10px_rgba(106,244,249,0.1)]">
Beta
</span>
</div>
</div>
{/* 右侧操作区 */}
<div data-alt="actions" className="flex items-center gap-2">
{
!drawerOpen && (
<>
{isLogin ? (
<button
data-alt="upgrade-icon-button"
className="h-9 w-9 rounded-full flex items-center justify-center text-gray-800 bg-gray-100 hover:bg-gray-200 border border-black/10 dark:text-white dark:bg-white/10 dark:hover:bg-white/20 dark:border-white/20"
onClick={handleUpgrade}
aria-label="Upgrade"
>
<Rocket className="h-4 w-4" />
</button>
) : (
<button
data-alt="signup-button"
className="px-3 h-9 rounded-full text-sm bg-white text-black hover:bg-white/90 border border-black/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:border-white/20"
onClick={() => router.push('/signup')}
>
Sign up
</button>
)}
</>
)
}
{/* 菜单抽屉antd Drawer */}
<button
data-alt="menu-trigger"
className="relative h-9 w-9 rounded-full flex items-center justify-center bg-gray-100 hover:bg-gray-200 text-gray-800 border border-black/10 dark:bg-white/10 dark:hover:bg-white/20 dark:text-white dark:border-white/20"
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen((v) => !v)}
>
{
drawerOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />
}
{needsSigninBadge && !drawerOpen && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-red-500 border border-white dark:border-[#0b0b0b]"></span>
)}
</button>
<Drawer
placement="top"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={null}
closable
height={undefined}
maskClosable
// 64px 顶栏高度 + 8px 安全间距
styles={{
content: { position: 'absolute', top: '3.5rem', height: isHome ? 'auto' : 'calc(100dvh - 3.5rem)' },
body: { padding: 0 },
header: { display: 'none' },
mask: { position: 'absolute', top: '3.5rem', height: 'calc(100dvh - 3.5rem)', backgroundColor: 'transparent' },
}}
className="[&_.ant-drawer-content]:bg-white [&_.ant-drawer-content]:text-black dark:[&_.ant-drawer-content]:bg-[#0b0b0b] dark:[&_.ant-drawer-content]:text-white dark:[&_.ant-drawer-body]:bg-[#0b0b0b] dark:[&_.ant-drawer-body]:text-white"
>
<div data-alt="drawer-container" className="h-full flex flex-col">
{/* 用户信息/未登录头部 */}
{isLogin ? (
<div data-alt="user-header" className="p-4 border-b border-t border-black/10 dark:border-white/10 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[#C73BFF] text-white flex items-center justify-center">
{(user?.username || user?.name || 'MF').slice(0, 1)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user?.name || user?.username || 'User'}</div>
<div className="text-xs text-black/60 dark:text-white/60 truncate">{user?.email}</div>
</div>
</div>
) : (
<></>
)}
{/* 内容区 */}
<div data-alt="drawer-content" className="flex-1 overflow-y-auto">
{isLogin ? (
<div className="p-4 space-y-4">
{/* 积分中心 */}
<div data-alt="wallet-card" className="rounded-xl border border-black/10 dark:border-white/10 p-4 bg-white dark:bg-white/5">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
{/* 签到 */}
<button
type="button"
onClick={handleSignin}
className="w-9 h-9 p-2 rounded-full bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-colors relative"
data-alt="share-entry-button"
title="Share"
>
<span className={`inline-block ${needsSigninBadge ? 'motion-safe:animate-wiggle' : ''}`}>
<CalendarDays className="h-5 w-5 text-white" />
</span>
{needsSigninBadge && (
<span className="absolute top-0 right-0 h-2 w-2 rounded-full bg-red-500 border border-white dark:border-[#0b0b0b]"></span>
)}
</button>
{/* 积分 */}
<div className="text-2xl font-semibold mt-1">{credits} <span className="text-xs text-black/60 dark:text-white/60">credits</span></div>
</div>
</div>
<button
className="relative h-8 w-8 rounded-full bg-white/10 dark:bg-white/10 flex items-center justify-center hover:bg-white/20"
onClick={() => router.push("/usage")}
title="Usage"
>
<Info className="h-4 w-4" />
</button>
</div>
{/* 快捷充值 */}
<div className="mt-3 flex flex-wrap gap-2">
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(100)}>+100 ($1)</button>
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(500)}>+500 ($5)</button>
<button className="px-2 py-1 text-xs rounded bg-black text-white" disabled={isBuyingTokens} onClick={() => handleBuyTokens(1000)}>+1000 ($10)</button>
</div>
{/* 自定义充值 */}
<div className="mt-2 flex items-center gap-2">
<input
type="number"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="Custom amount"
className="w-[120px] h-9 px-2 text-sm bg-white/10 text-black dark:text-white placeholder-black/40 dark:placeholder-white/60 border border-black/20 dark:border-white/20 rounded focus:outline-none"
min={1}
/>
<button
className="px-3 h-9 rounded bg-black text-white text-sm disabled:opacity-50"
disabled={!customAmount || parseInt(customAmount) <= 0 || isBuyingTokens}
onClick={handleCustomAmountBuy}
>
Buy
</button>
</div>
</div>
{/* 菜单目录 */}
<div data-alt="menu-links" className="rounded-xl border border-black/10 dark:border-white/10 divide-y divide-black/10 dark:divide-white/10 overflow-hidden">
{navigationItems.map((group) => (
group.items.map((nav) => {
const isActive = pathname === nav.href || pathname.startsWith(nav.href + '/');
return (
<button
key={nav.href}
data-alt={`link-${nav.name.toLowerCase()}`}
aria-current={isActive ? 'page' : undefined}
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-white/10 ${isActive ? 'bg-gray-100 dark:bg-white/10' : ''}`}
onClick={() => router.push(nav.href)}
>
<nav.icon className="h-4 w-4" />
<span>{nav.name}</span>
</button>
);
})
))}
</div>
{/* 其他功能 */}
<div className="space-y-2">
<button data-alt="upgrade-button" className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 hover:bg-gray-50 dark:hover:bg-white/10" onClick={handleUpgrade}>Upgrade</button>
{planName !== 'none' && subscriptionStatus !== 'INACTIVE' && (
<button data-alt="manage-button" className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 hover:bg-gray-50 dark:hover:bg-white/10" onClick={handleManageSubscription} disabled={isManagingSubscription}>Manage</button>
)}
</div>
</div>
) : (
<div className="p-4 space-y-4 pt-0">
{isHome && homeTabs.length > 0 && (
<div data-alt="home-tabs" className="rounded-xl border border-black/10 dark:border-white/10 overflow-hidden">
{homeTabs.map((tab) => (
<button
key={tab.title}
data-alt={`home-tab-${tab.title.toLowerCase()}`}
className="w-full text-left px-4 py-3 border-b border-black/10 dark:border-white/10 last:border-b-0 hover:bg-gray-50 dark:hover:bg-white/10"
onClick={() => {
if (onSelectHomeTab) onSelectHomeTab(tab.title.toLowerCase() as any);
setDrawerOpen(false);
}}
>
{tab.title}
</button>
))}
</div>
)}
<div className="flex gap-2">
<button
data-alt="login-button"
className="flex-1 h-10 rounded-full border border-black/20 dark:border-white/20"
onClick={() => router.push('/login')}
>
Log in
</button>
<button
data-alt="signup-button-secondary"
className="flex-1 h-10 rounded-full bg-black text-white hover:bg-black/90"
onClick={() => router.push('/signup')}
>
Sign up
</button>
</div>
</div>
)}
</div>
{/* 底部操作 */}
{isLogin && (
<div data-alt="drawer-footer" className="p-4 border-t border-black/10 dark:border-white/10">
<button
data-alt="logout-button"
className="w-full h-10 rounded-full border border-black/20 dark:border-white/20 flex items-center justify-center gap-2"
onClick={() => logoutUser()}
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
)}
</div>
</Drawer>
{/* Sign-in Modal */}
<Dialog open={isSigninModalOpen} onOpenChange={(open) => {
setIsSigninModalOpen(open);
if (!open) {
// 刷新积分
if (user?.id) { getUserSubscriptionInfo(String(user.id)).then((res:any)=>{ if(res?.data?.credits!==undefined){ setCredits(res.data.credits) } }).catch(()=>{}); }
// 关闭签到弹窗后,重新检查红点
if (isLogin) { getSigninStatus().then((d:any)=> setNeedsSigninBadge(!d?.data?.has_signin)).catch(()=>{}); }
}
}}>
<DialogContent
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
data-alt="signin-modal"
>
<DialogTitle></DialogTitle>
<SigninBox onSuccess={async () => {
try {
if (user?.id) {
const res = await getUserSubscriptionInfo(String(user.id));
if (res?.data?.credits !== undefined) {
setCredits(res.data.credits);
}
}
} catch {}
// 成功签到后去除红点
setNeedsSigninBadge(false);
}} />
</DialogContent>
</Dialog>
</div>
</div>
</div>
</>
);
}