forked from 77media/video-flow
570 lines
22 KiB
TypeScript
570 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import "../pages/style/top-bar.css";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { GradientText } from "@/components/ui/gradient-text";
|
||
import { useTheme } from "next-themes";
|
||
import {
|
||
Sun,
|
||
Moon,
|
||
User,
|
||
Sparkles,
|
||
LogOut,
|
||
PanelsLeftBottom,
|
||
Bell,
|
||
Info,
|
||
CalendarDays,
|
||
} from "lucide-react";
|
||
import { motion } from "framer-motion";
|
||
import { createPortal } from "react-dom";
|
||
import { useRouter, usePathname } from "next/navigation";
|
||
import React, { useRef, useEffect, useLayoutEffect, useState } from "react";
|
||
import { logoutUser } from "@/lib/auth";
|
||
import {
|
||
createPortalSession,
|
||
redirectToPortal,
|
||
getUserSubscriptionInfo,
|
||
buyTokens,
|
||
} from "@/lib/stripe";
|
||
import UserCard from "@/components/common/userCard";
|
||
import { showInsufficientPointsNotification } from "@/utils/notifications";
|
||
import SigninBox from "./signin-box";
|
||
|
||
interface User {
|
||
id: string;
|
||
name: string;
|
||
email: string;
|
||
avatar: string;
|
||
username: string;
|
||
plan_name?: string;
|
||
}
|
||
|
||
export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDesktop?: boolean }) {
|
||
const router = useRouter();
|
||
const [isOpen, setIsOpen] = React.useState(false);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||
const [currentUser, setCurrentUser] = useState<User>(
|
||
JSON.parse(localStorage.getItem("currentUser") || "{}")
|
||
);
|
||
const pathname = usePathname();
|
||
const [mounted, setMounted] = React.useState(false);
|
||
const [isLogin, setIsLogin] = useState(false);
|
||
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
|
||
const [subscriptionStatus, setSubscriptionStatus] = useState<string>("");
|
||
const [credits, setCredits] = useState<number>(0);
|
||
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
|
||
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
|
||
const [customAmount, setCustomAmount] = useState<string>("");
|
||
const [isSigninModalOpen, setIsSigninModalOpen] = useState(false);
|
||
|
||
// 获取用户订阅信息
|
||
const fetchSubscriptionInfo = async () => {
|
||
if (!currentUser?.id) return;
|
||
|
||
setIsLoadingSubscription(true);
|
||
try {
|
||
const response = await getUserSubscriptionInfo(String(currentUser.id));
|
||
if (response.successful && response.data) {
|
||
const status = response.data.subscription_status;
|
||
setSubscriptionStatus(status);
|
||
setCredits(response.data.credits);
|
||
|
||
// 更新 currentUser 的 plan_name
|
||
setCurrentUser((prev) => ({
|
||
...prev,
|
||
plan_name: response.data.plan_name, // HACK
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error("获取订阅信息失败:", error);
|
||
} finally {
|
||
setIsLoadingSubscription(false);
|
||
}
|
||
};
|
||
|
||
// 处理Token购买(同域新页 + postMessage 方式重定向)
|
||
const handleBuyTokens = async (tokenAmount: number) => {
|
||
if (!currentUser?.id) {
|
||
console.error("用户未登录");
|
||
return;
|
||
}
|
||
|
||
if (tokenAmount <= 0) {
|
||
console.error("Token数量必须大于0");
|
||
return;
|
||
}
|
||
|
||
// 先同步打开同域新页面,避免被拦截
|
||
const redirectWindow = window.open("/pay-redirect", "_blank");
|
||
if (!redirectWindow) {
|
||
console.error("无法打开支付重定向页面,可能被浏览器拦截");
|
||
return;
|
||
}
|
||
|
||
setIsBuyingTokens(true);
|
||
try {
|
||
const response = await buyTokens({
|
||
token_amount: tokenAmount,
|
||
package_type: "basic"
|
||
});
|
||
|
||
if (response.successful && response.data?.checkout_url) {
|
||
// 通知当前窗口等待支付,标识为Token购买
|
||
window.postMessage({
|
||
type: "waiting-payment",
|
||
paymentType: "token"
|
||
}, "*");
|
||
sessionStorage.setItem('session_id', response.data.session_id);
|
||
// 通过 postMessage 通知新页面进行重定向
|
||
redirectWindow.postMessage({
|
||
type: "redirect-to-payment",
|
||
url: response.data.checkout_url
|
||
}, window.location.origin);
|
||
} else {
|
||
console.error("创建Token购买失败:", response.message);
|
||
// 通知新页显示错误
|
||
redirectWindow.postMessage({
|
||
type: "redirect-error",
|
||
message: response.message || "创建支付失败"
|
||
}, window.location.origin);
|
||
}
|
||
} catch (error: unknown) {
|
||
console.error("Token购买失败:", error);
|
||
try {
|
||
redirectWindow.postMessage({
|
||
type: "redirect-error",
|
||
message: "网络或服务异常,请关闭此页重试"
|
||
}, window.location.origin);
|
||
} catch {}
|
||
} finally {
|
||
setIsBuyingTokens(false);
|
||
}
|
||
};
|
||
|
||
// 处理自定义金额购买
|
||
const handleCustomAmountBuy = async () => {
|
||
const amount = parseInt(customAmount);
|
||
if (isNaN(amount) || amount <= 0) {
|
||
console.error("请输入有效的Token数量");
|
||
return;
|
||
}
|
||
await handleBuyTokens(amount);
|
||
setCustomAmount(""); // 清空输入框
|
||
};
|
||
|
||
useEffect(() => {
|
||
const token = localStorage.getItem("token");
|
||
if (token) {
|
||
setIsLogin(true);
|
||
} else {
|
||
setIsLogin(false);
|
||
}
|
||
});
|
||
useLayoutEffect(() => {
|
||
console.log("Setting mounted state");
|
||
setMounted(true);
|
||
return () => console.log("Cleanup mounted effect");
|
||
}, []);
|
||
|
||
// 处理订阅管理
|
||
const handleManageSubscription = async () => {
|
||
if (!currentUser?.id) {
|
||
console.error("用户未登录");
|
||
return;
|
||
}
|
||
|
||
setIsManagingSubscription(true);
|
||
try {
|
||
const response = await createPortalSession({
|
||
user_id: String(currentUser.id),
|
||
return_url: window.location.origin + "/dashboard",
|
||
});
|
||
|
||
if (response.successful && response.data?.portal_url) {
|
||
redirectToPortal(response.data.portal_url);
|
||
} else {
|
||
console.log("cannot open the manage subscription");
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.log("cannot open the manage subscription");
|
||
return;
|
||
} finally {
|
||
setIsManagingSubscription(false);
|
||
}
|
||
};
|
||
|
||
// 处理点击事件
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
let isClickStartedInside = false;
|
||
|
||
const handleMouseDown = (event: MouseEvent) => {
|
||
const target = event.target as Node;
|
||
isClickStartedInside = !!(
|
||
menuRef.current?.contains(target) || buttonRef.current?.contains(target)
|
||
);
|
||
};
|
||
|
||
const handleMouseUp = (event: MouseEvent) => {
|
||
const target = event.target as Node;
|
||
const isClickEndedInside = !!(
|
||
menuRef.current?.contains(target) || buttonRef.current?.contains(target)
|
||
);
|
||
|
||
// 只有当点击开始和结束都在外部时才关闭
|
||
if (!isClickStartedInside && !isClickEndedInside) {
|
||
setIsOpen(false);
|
||
}
|
||
isClickStartedInside = false;
|
||
};
|
||
|
||
// 在冒泡阶段处理事件
|
||
document.addEventListener("mousedown", handleMouseDown);
|
||
document.addEventListener("mouseup", handleMouseUp);
|
||
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleMouseDown);
|
||
document.removeEventListener("mouseup", handleMouseUp);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleAnimationEnd = (event: React.AnimationEvent<HTMLDivElement>) => {
|
||
const element = event.currentTarget;
|
||
element.classList.remove("on");
|
||
};
|
||
|
||
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
|
||
const element = event.currentTarget;
|
||
element.classList.add("on");
|
||
};
|
||
|
||
/**
|
||
* 处理签到功能,打开签到modal
|
||
*/
|
||
const handleSignin = () => {
|
||
setIsSigninModalOpen(true);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="fixed right-0 top-0 h-16 header z-[999]"
|
||
style={{
|
||
isolation: "isolate",
|
||
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")
|
||
}}
|
||
>
|
||
<div className="h-full flex items-center justify-between px-4">
|
||
<div className="flex items-center space-x-4">
|
||
<div
|
||
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
|
||
onClick={() => router.push("/")}
|
||
onMouseEnter={handleMouseEnter}
|
||
onAnimationEnd={handleAnimationEnd}
|
||
>
|
||
<span className="translate">
|
||
<span>
|
||
<h1 className="logo text-2xl font-bold">
|
||
<GradientText
|
||
text="MovieFlow"
|
||
startPercentage={30}
|
||
endPercentage={70}
|
||
/>
|
||
</h1>
|
||
</span>
|
||
<span>
|
||
<h1 className="logo text-2xl font-bold">
|
||
<GradientText
|
||
text="MovieFlow"
|
||
startPercentage={30}
|
||
endPercentage={70}
|
||
/>
|
||
</h1>
|
||
</span>
|
||
</span>
|
||
{/* 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>
|
||
|
||
{isLogin ? (
|
||
<div className="flex items-center space-x-2">
|
||
{/* Pricing Link */}
|
||
{pathname === "/" ? (
|
||
<div
|
||
data-alt="go-started-button"
|
||
className="z-100 pointer-events-auto bg-white text-black rounded-full cursor-pointer transition-opacity opacity-100 hover:opacity-80 font-medium
|
||
/* 移动端适配 */
|
||
px-2 py-1.5 text-xs
|
||
/* 平板及以上适配 */
|
||
sm:px-4 sm:py-2 sm:text-sm"
|
||
onClick={() => router.push("/movies")}
|
||
>
|
||
Go Started
|
||
</div>
|
||
) : (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
localStorage.setItem("callBackUrl", pathname);
|
||
window.open("/pricing", "_blank");
|
||
}}
|
||
className="text-gray-300 hover:text-white"
|
||
>
|
||
Upgrade
|
||
</Button>
|
||
)}
|
||
|
||
{/* Notifications */}
|
||
{/* <Button variant="ghost" size="sm" onClick={() => showInsufficientPointsNotification({
|
||
current_balance: 20,
|
||
required_tokens: 100,
|
||
message: 'Insufficient points'
|
||
})}>
|
||
<Bell className="h-4 w-4" />
|
||
</Button> */}
|
||
|
||
{/* Theme Toggle */}
|
||
{/* <Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||
>
|
||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||
</Button> */}
|
||
|
||
{/* User Menu */}
|
||
<div className="relative" style={{ isolation: "isolate" }}>
|
||
<Button
|
||
ref={buttonRef}
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
console.log("Button clicked, current isOpen:", isOpen);
|
||
if (!isOpen) {
|
||
// 每次打开菜单时重新获取订阅信息
|
||
fetchSubscriptionInfo();
|
||
}
|
||
setIsOpen(!isOpen);
|
||
}}
|
||
data-alt="user-menu-trigger"
|
||
style={{
|
||
background: "unset !important",
|
||
padding: "unset !important",
|
||
}}
|
||
>
|
||
<div
|
||
className={`${isDesktop ? "h-10 w-10" : "h-8 w-8"} rounded-full bg-[#C73BFF] flex items-center justify-center text-white font-semibold`}
|
||
>
|
||
{currentUser.username ? currentUser.username.charAt(0) : "MF"}
|
||
</div>
|
||
</Button>
|
||
|
||
{mounted && isOpen
|
||
? ((createPortal as any)(
|
||
<motion.div
|
||
ref={menuRef}
|
||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||
transition={{ duration: 0.2 }}
|
||
style={{
|
||
position: "fixed",
|
||
top: "4rem",
|
||
right: "1rem",
|
||
zIndex: 999,
|
||
}}
|
||
className="overflow-hidden rounded-xl max-h-[90vh]"
|
||
data-alt="user-menu-dropdown"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<UserCard plan_name={currentUser.plan_name}>
|
||
<div className="relative z-[2] w-full max-h-[80vh] flex flex-col text-white p-3 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
|
||
{/* 顶部用户信息 */}
|
||
<div className="flex items-center space-x-3 mb-3">
|
||
<div className="h-10 w-10 rounded-full bg-[#C73BFF] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||
{currentUser.username
|
||
? currentUser.username.charAt(0)
|
||
: "MF"}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="text-sm font-semibold text-white truncate">
|
||
{currentUser.name || currentUser.username}
|
||
</h3>
|
||
<p className="text-xs text-gray-300 truncate">
|
||
{currentUser.email}
|
||
</p>
|
||
</div>
|
||
{/* Sign-in entry */}
|
||
<div>
|
||
<button
|
||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||
onClick={() => handleSignin()}
|
||
title="Daily Sign-in"
|
||
>
|
||
<CalendarDays className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI 积分 */}
|
||
<div className="flex flex-col items-center mb-3">
|
||
<div className="flex items-center justify-center space-x-3 mb-2">
|
||
<div className="p-2 rounded-full bg-white/10 backdrop-blur-sm">
|
||
<Sparkles className="h-5 w-5 text-white" />
|
||
</div>
|
||
<span className="text-white text-base font-semibold">
|
||
{isLoadingSubscription
|
||
? "Loading..."
|
||
: `${credits} credits`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => window.open("/usage", "_blank")}
|
||
className="ml-1 inline-flex items-center justify-center h-6 w-6 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||
data-alt="credits-info-button"
|
||
title="Usage"
|
||
>
|
||
<Info className="h-4 w-4 text-white" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Purchase Credits 按钮 */}
|
||
<div className="flex flex-wrap gap-1 justify-center mb-1">
|
||
<button
|
||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||
onClick={() => handleBuyTokens(100)}
|
||
disabled={isBuyingTokens}
|
||
>
|
||
+100 ($1)
|
||
</button>
|
||
<button
|
||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||
onClick={() => handleBuyTokens(500)}
|
||
disabled={isBuyingTokens}
|
||
>
|
||
+500 ($5)
|
||
</button>
|
||
<button
|
||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||
onClick={() => handleBuyTokens(1000)}
|
||
disabled={isBuyingTokens}
|
||
>
|
||
+1000 ($10)
|
||
</button>
|
||
</div>
|
||
|
||
{/* 自定义金额输入 */}
|
||
<div className="flex items-center space-x-1">
|
||
<input
|
||
type="number"
|
||
value={customAmount}
|
||
onChange={(e) => setCustomAmount(e.target.value)}
|
||
placeholder="Custom amount"
|
||
className="flex-1 px-2 py-1 text-xs bg-white/10 text-white placeholder-white/60 border border-white/20 rounded focus:outline-none focus:border-blue-400"
|
||
min="1"
|
||
disabled={isBuyingTokens}
|
||
/>
|
||
<button
|
||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||
onClick={handleCustomAmountBuy}
|
||
disabled={isBuyingTokens || !customAmount || parseInt(customAmount) <= 0}
|
||
>
|
||
Buy (${customAmount ? (parseInt(customAmount) / 100).toFixed(2) : '0.00'})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮区域 */}
|
||
<div className="flex-1 flex flex-row justify-center items-end space-x-1">
|
||
<button
|
||
className="flex-1 bg-transparent border border-white/30 text-white text-xs py-0.5 h-6 rounded hover:bg-white/10 transition-colors"
|
||
onClick={() => {
|
||
window.open("/pricing", "_blank");
|
||
}}
|
||
>
|
||
Upgrade
|
||
</button>
|
||
|
||
{currentUser.plan_name !== "none" && subscriptionStatus !== 'INACTIVE' && (
|
||
<button
|
||
className="flex-1 bg-transparent border border-gray-400/30 text-gray-300 text-xs py-0.5 h-6 rounded hover:bg-gray-400/10 transition-colors disabled:opacity-50"
|
||
onClick={handleManageSubscription}
|
||
disabled={isManagingSubscription}
|
||
>
|
||
Manage
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
className="flex-1 bg-transparent border border-red-400/50 text-red-300 text-xs py-0.5 h-6 rounded hover:bg-red-400/10 transition-colors"
|
||
onClick={() => logoutUser()}
|
||
>
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</UserCard>
|
||
</motion.div>,
|
||
document.body
|
||
) as unknown as React.ReactNode)
|
||
: null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center space-x-2 sm:space-x-4">
|
||
<div
|
||
data-alt="login-button"
|
||
className="z-100 pointer-events-auto text-gray-300 hover:text-white cursor-pointer rounded transition-colors
|
||
/* 移动端适配 */
|
||
px-2 py-1.5 text-xs
|
||
/* 平板及以上适配 */
|
||
sm:px-3 sm:py-2 sm:text-sm"
|
||
onClick={() => router.push("/signup")}
|
||
>
|
||
Sign Up
|
||
</div>
|
||
<div
|
||
data-alt="go-started-button"
|
||
className="z-100 pointer-events-auto bg-white text-black rounded-full cursor-pointer transition-opacity opacity-100 hover:opacity-80 font-medium
|
||
/* 移动端适配 */
|
||
px-2 py-1.5 text-xs
|
||
/* 平板及以上适配 */
|
||
sm:px-4 sm:py-2 sm:text-sm"
|
||
onClick={() => router.push("/movies")}
|
||
>
|
||
Go Started
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sign-in Modal */}
|
||
<Dialog open={isSigninModalOpen} onOpenChange={setIsSigninModalOpen}>
|
||
<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>
|
||
<div className="p-4">
|
||
<SigninBox />
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|