2025-10-24 20:27:20 +08:00

547 lines
22 KiB
TypeScript
Raw Permalink 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 "../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,
Gift,
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购买改为携带参数打开 pay-redirect
const handleBuyTokens = async (tokenAmount: number) => {
if (!currentUser?.id) {
console.error("用户未登录");
return;
}
if (tokenAmount <= 0) {
console.error("Token数量必须大于0");
return;
}
// 直接打开带参数的 pay-redirect新窗口内自行创建会话并跳转
const url = `/pay-redirect?type=token&amount=${encodeURIComponent(tokenAmount)}&pkg=basic`;
window.open(url, "_blank");
// 通知当前窗口等待支付显示loading模态框
window.postMessage({
type: 'waiting-payment',
paymentType: 'subscription',
}, '*');
};
// 处理自定义金额购买
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(""); // 清空输入框
};
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-[20]"
style={{
isolation: "isolate",
left: (pathname === "/" || !isDesktop) ? "0" : (collapsed ? "2.5rem" : "16rem")
}}
>
<div className={`h-full flex items-center justify-between pr-4 md:px-4 ${pathname !== "/" ? "px-4" : ""}`}>
<div className="flex items-center md:space-x-4">
{pathname === "/" && (
<button
data-alt="mobile-menu-toggle"
className="md:hidden text-white/90 p-[0.8rem]"
onClick={() => window.dispatchEvent(new CustomEvent('home-menu-toggle'))}
>
</button>
)}
<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>
<div className="logo text-2xl font-bold">
<GradientText
text="MovieFlow"
startPercentage={30}
endPercentage={70}
/>
</div>
</span>
<span>
<div className="logo text-2xl font-bold">
<GradientText
text="MovieFlow"
startPercentage={30}
endPercentage={70}
/>
</div>
</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("/home")}
>
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">
<button
type="button"
onClick={() => router.push("/share")}
className="w-9 h-9 p-2 rounded-full bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-colors"
data-alt="share-entry-button"
title="Share"
>
<span className="inline-block motion-safe:animate-wiggle">
<Gift className="h-5 w-5 text-white" />
</span>
</button>
<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="50"
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("/home")}
>
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>
<SigninBox />
</DialogContent>
</Dialog>
</div>
);
}