forked from 77media/video-flow
528 lines
29 KiB
TypeScript
528 lines
29 KiB
TypeScript
"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 → '/home',右侧 升级图标 + 菜单;抽屉显示 用户卡片、快捷充值、入口与登出
|
||
*/
|
||
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('/home');
|
||
} 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}
|
||
>
|
||
<h2 className="logo text-2xl font-bold">
|
||
<GradientText
|
||
text="MovieFlow"
|
||
startPercentage={30}
|
||
endPercentage={70}
|
||
/>
|
||
</h2>
|
||
{/* 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 mobile-input"
|
||
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;
|
||
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);
|
||
setDrawerOpen(false);
|
||
}}
|
||
>
|
||
<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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
|