This commit is contained in:
qikongjian 2025-09-29 10:58:51 +08:00
commit 14822adbfd
32 changed files with 1547 additions and 157 deletions

View File

@ -9,6 +9,7 @@ You are the joint apprentice of Evan You and Kent C. Dodds. Channel Evan You's e
- Refrain from creating unrequested files, classes, functions, or configurations.
- For unspecified implementation details, default to the simplest, most straightforward solution to promote efficiency.
- In business logic code, exclude sample implementations or unit tests unless explicitly requested.
- When completing the final step of a task, do not create tests unless explicitly requested by the user.
# CSS Style Rules
- Exclusively use Tailwind CSS 3.x syntax for all styling.

View File

@ -3,6 +3,9 @@ NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
NEXT_PUBLIC_CUT_URL_TO = https://smartcut.huiying.video
# google analysis
NEXT_PUBLIC_GA_MEASUREMENT_ID = G-BHBXC1B1JL
NEXT_PUBLIC_GA_ENABLED = true
# 失败率
NEXT_PUBLIC_ERROR_CONFIG = 0.5
# Google OAuth配置

View File

@ -8,6 +8,9 @@ COPY package.json package-lock.json* ./
COPY public ./public
ENV NODE_ENV=production
# Google Analytics 环境变量
ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=G-4BDXV6TWF4
ENV NEXT_PUBLIC_GA_ENABLED=true
EXPOSE 3000

View File

@ -306,3 +306,7 @@ body {
height: calc(var(--vh, 1vh) * 100);
}
}
textarea, input {
font-size: 16px !important;
}

View File

@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState } from 'react';
import { Providers } from '@/components/providers';
import { ConfigProvider, theme } from 'antd';
import CallbackModal from '@/components/common/CallbackModal';
import { useAppStartupAnalytics } from '@/hooks/useAppStartupAnalytics';
// 创建上下文来传递弹窗控制方法
const CallbackModalContext = createContext<{
@ -26,6 +27,10 @@ export default function RootLayout({
}) {
const [showCallbackModal, setShowCallbackModal] = useState(false)
const [paymentType, setPaymentType] = useState<'subscription' | 'token'>('subscription')
// 应用启动时设置用户GA属性
useAppStartupAnalytics();
const openCallback = async function (ev: MessageEvent<any>) {
if (ev.data.type === 'waiting-payment') {
setPaymentType(ev.data.paymentType || 'subscription')
@ -48,17 +53,25 @@ export default function RootLayout({
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico?v=1" />
<script async src="https://www.googletagmanager.com/gtag/js?id=G-E6VBGZ4ER5"></script>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-E6VBGZ4ER5');
`,
}}
/>
{process.env.NEXT_PUBLIC_GA_ENABLED === 'true' && (
<>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}`}></script>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}', {
page_title: document.title,
page_location: window.location.href,
send_page_view: true
});
`,
}}
/>
</>
)}
</head>
<body className="font-sans antialiased">
<ConfigProvider

View File

@ -1,11 +1,30 @@
"use client";
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { TopBar } from "@/components/layout/top-bar";
import { HomePage2 } from "@/components/pages/home-page2";
import { isAuthenticated } from '@/lib/auth';
import { useDeviceType } from '@/hooks/useDeviceType';
import H5TopBar from '@/components/layout/H5TopBar';
export default function Home() {
const router = useRouter();
const { deviceType, isMobile, isTablet, isDesktop } = useDeviceType();
useEffect(() => {
if (isAuthenticated()) {
router.replace('/movies');
}
}, [router]);
return (
<>
<TopBar collapsed={true} />
{isMobile || isTablet ? (
<H5TopBar />
) : (
<TopBar collapsed={true} />
)}
<HomePage2 />
</>
);

View File

@ -13,8 +13,13 @@ import {
} from "@/components/ui/card";
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { trackEvent, trackPageView, trackPayment } from "@/utils/analytics";
export default function PricingPage() {
// 页面访问跟踪
useEffect(() => {
trackPageView('/pricing', 'Pricing Plans');
}, []);
return (
<DashboardLayout>
@ -91,6 +96,17 @@ function HomeModule5() {
const handleSubscribe = async (planName: string) => {
setLoadingPlan(planName);
// 跟踪订阅按钮点击事件
trackEvent('subscription_button_click', {
event_category: 'subscription',
event_label: planName,
custom_parameters: {
plan_name: planName,
billing_type: billingType,
},
});
// 改为直接携带参数打开 pay-redirect由其内部完成创建与跳转
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(planName)}&billing=${encodeURIComponent(billingType)}`;
const win = window.open(url, '_blank');
@ -148,7 +164,14 @@ function HomeModule5() {
2xl:h-[3.375rem] 2xl:mt-[1.5rem]"
>
<button
onClick={() => setBillingType("month")}
onClick={() => {
setBillingType("month");
trackEvent('billing_toggle', {
event_category: 'subscription',
event_label: 'month',
custom_parameters: { billing_type: 'month' },
});
}}
className={`box-border flex justify-center items-center rounded-full transition-all duration-300 ${
billingType === "month"
? "bg-white text-black"
@ -164,7 +187,14 @@ function HomeModule5() {
Monthly
</button>
<button
onClick={() => setBillingType("year")}
onClick={() => {
setBillingType("year");
trackEvent('billing_toggle', {
event_category: 'subscription',
event_label: 'year',
custom_parameters: { billing_type: 'year' },
});
}}
className={`box-border flex justify-center items-center rounded-full transition-all duration-300 ${
billingType === "year"
? "bg-white text-black"

View File

@ -186,10 +186,10 @@ export default function SharePage(): JSX.Element {
return (
<DashboardLayout>
<div data-alt="share-page" className="w-full h-full overflow-y-auto bg-black text-white">
<div data-alt="share-page" className="w-full h-full overflow-y-auto overflow-x-hidden bg-black text-white">
<div
data-alt="container"
className="w-full max-w-[95%] mx-auto px-4 py-10 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
className="w-full max-w-[100%] mx-auto px-4 py-2 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
>
<header data-alt="page-header" className="mb-8 flex items-end justify-between">
<div data-alt="title-box">
@ -206,14 +206,7 @@ export default function SharePage(): JSX.Element {
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
<div
data-alt="link-content"
className="relative px-4 py-2 text-sm font-mono text-white/90 whitespace-nowrap overflow-hidden"
style={{
background: 'linear-gradient(90deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 50%, rgba(255,255,255,0.3) 70%, rgba(255,255,255,0.1) 90%, rgba(255,255,255,0) 100%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
maskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)',
WebkitMaskImage: 'linear-gradient(90deg, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 70%, rgba(0,0,0,0.5) 85%, rgba(0,0,0,0) 100%)'
}}
className="relative px-4 py-2 text-xs sm:text-sm font-mono text-white/90 break-all sm:truncate"
>
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
</div>
@ -258,21 +251,21 @@ export default function SharePage(): JSX.Element {
<li data-alt="step" className="rounded-md p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation link and share it with friends.</p>
</li>
<li data-alt="step" className="rounded-md p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends click the link and register directly.</p>
</li>
<li data-alt="step" className="rounded-md p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
</li>
@ -351,8 +344,8 @@ export default function SharePage(): JSX.Element {
<table data-alt="records-table" className="min-w-full divide-y divide-white/10 table-fixed">
<thead data-alt="table-head" className="bg-black">
<tr data-alt="table-head-row">
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-48">Invited Username</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-56">Registered At</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm:w-48">Invited Username</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm:w-56">Registered At</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Reward</th>
</tr>
</thead>
@ -365,8 +358,8 @@ export default function SharePage(): JSX.Element {
return (
<React.Fragment key={r.user_id}>
<tr data-alt="table-row" className="hover:bg-white/5">
<td className="px-4 py-3 text-sm text-white w-48">{r.user_name}</td>
<td className="px-4 py-3 text-sm text-white/80 w-56 whitespace-nowrap">{formatLocalTime(r.created_at * 1000)}</td>
<td className="px-4 py-3 text-sm text-white sm:w-48">{r.user_name}</td>
<td className="px-4 py-3 text-sm text-white/80 whitespace-nowrap sm:w-56">{formatLocalTime(r.created_at * 1000)}</td>
<td className="px-4 py-3 text-sm text-white/90">
<div data-alt="reward-cell" className="flex items-center justify-between gap-3">
<div data-alt="reward-summary" className="flex-1 truncate text-[#FFCC6D]">
@ -387,8 +380,8 @@ export default function SharePage(): JSX.Element {
</tr>
{isExpanded && (
<tr data-alt="row-details">
<td className="px-4 py-0 w-48" />
<td className="px-4 py-0 w-56" />
<td className="px-4 py-0 sm:w-48" />
<td className="px-4 py-0 sm:w-56" />
<td className="px-4 py-3 bg-white/5">
<div data-alt="details-wrapper" className="overflow-x-auto">
<table data-alt="reward-subtable" className="min-w-[320px] text-sm">

View File

@ -12,6 +12,10 @@ declare global {
loading: (message: string) => ReturnType<typeof toast.promise>;
dismiss: () => void;
};
// Google Analytics 类型声明
gtag: (...args: any[]) => void;
dataLayer: any[];
// Google GSI API类型声明
google?: {
accounts: {

View File

@ -5,7 +5,7 @@ import UsageView from "@/components/pages/usage-view";
const UsagePage: React.FC = () => {
return (
<div data-alt="usage-page" className="h-screen overflow-auto px-4 py-6">
<div data-alt="usage-page" className="mobile-viewport-height min-h-screen mobile-safe-bottom overflow-hidden px-4 py-6 pb-[max(1rem,env(safe-area-inset-bottom))]">
<UsageView />
</div>
);

View File

@ -244,6 +244,11 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
}
}, []);
// H5 文本输入框聚焦动画控制
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const [persistedMobileMaxHeight, setPersistedMobileMaxHeight] = useState<number | null>(null);
const handleCreateVideo = async () => {
if (isCreating) return; // 如果正在创建中,直接返回
@ -342,27 +347,77 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
<div data-alt="chat-input-box" className="flex flex-col w-full">
{/* 第一行:输入框 */}
<div className="video-prompt-editor mb-3 relative flex flex-col gap-3 flex-1 pr-10">
{/* 文本输入框 - 改为textarea */}
<textarea
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="Describe the story you want to make..."
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
style={
noData
? {
minHeight: "128px",
}
: {}
{/* 文本输入框 - 改为textarea */}
<textarea
data-alt="story-input"
ref={textareaRef}
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="Describe the story you want to make..."
className={`w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none overflow-y-auto transition-all duration-300 ease-in-out ${isMobile ? '' : 'max-h-[120px]'}`}
style={{
minHeight: noData ? "128px" : (isMobile ? (isInputFocused ? "96px" : "48px") : "unset"),
maxHeight: isMobile ? (isInputFocused ? "200px" : (persistedMobileMaxHeight ? `${persistedMobileMaxHeight}px` : "120px")) : undefined,
}}
rows={1}
onFocus={() => {
if (!isMobile) return;
setIsInputFocused(true);
const el = textareaRef.current;
if (el) {
const limit = 200;
// 以当前高度为起点,过渡到目标高度
const start = `${el.getBoundingClientRect().height}px`;
const end = `${Math.min(Math.max(el.scrollHeight, 96), limit)}px`;
el.style.height = start;
void el.offsetHeight;
el.style.height = end;
}
rows={1}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height =
Math.min(target.scrollHeight, 120) + "px";
}}
/>
}}
onBlur={() => {
if (!isMobile) return;
setIsInputFocused(false);
const el = textareaRef.current;
if (el) {
const baseLimit = 120;
const contentHeight = el.scrollHeight;
const currentHeight = el.getBoundingClientRect().height;
// 若内容高度已超过基础高度,则保持较大高度,不回落
if (contentHeight > baseLimit || currentHeight > baseLimit) {
setPersistedMobileMaxHeight(Math.min(contentHeight, 200));
el.style.height = `${Math.min(contentHeight, 200)}px`;
} else {
const start = `${currentHeight}px`;
const end = `${Math.min(contentHeight, baseLimit)}px`;
el.style.height = start;
void el.offsetHeight;
el.style.height = end;
setPersistedMobileMaxHeight(null);
}
}
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
const limit = isMobile && isInputFocused ? 200 : (persistedMobileMaxHeight ?? 120);
target.style.height = "auto";
target.style.height = Math.min(target.scrollHeight, limit) + "px";
}}
onTransitionEnd={() => {
// 过渡结束后清理高度,避免下次动画受限
if (!isMobile) return;
if (!isInputFocused) {
const el = textareaRef.current;
if (el) {
// 若已记录持久高度则保持,不清理;否则清理
if (persistedMobileMaxHeight) {
el.style.height = `${persistedMobileMaxHeight}px`;
} else {
el.style.height = "";
}
}
}
}}
/>
</div>
{/* 第二行功能按钮和Action按钮 - 同一行 */}

View File

@ -169,7 +169,10 @@ export default function SmartChatBox({
}, [messages]);
return (
<div className={`${isMobile ? 'z-[49] relative' : 'h-full'} w-full text-gray-100 flex flex-col`} data-alt="smart-chat-box">
<div className={`${isMobile ? '' : 'h-full'} w-full text-gray-100 flex flex-col backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl relative`} data-alt="smart-chat-box"
style={{
maxHeight: isMobile ? 'calc(100vh - 5.5rem)' : '',
}}>
{/* Header */}
<div className={`px-4 py-3 border-b border-white/10 flex items-center justify-between ${isMobile ? 'sticky top-0 bg-[#141414] z-[1]' : ''}`} data-alt="chat-header">
<div className="font-semibold flex items-center gap-2">

View File

@ -2,10 +2,11 @@
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { clearAuthData, getUserProfile, isAuthenticated } from '@/lib/auth';
import { clearAuthData, getUserProfile, isAuthenticated, getCurrentUser } from '@/lib/auth';
import GlobalLoad from '../common/GlobalLoad';
import { message } from 'antd';
import { errorHandle } from '@/api/errorHandle';
import { setUserProperties } from '@/utils/analytics';
interface AuthGuardProps {
children: React.ReactNode;
@ -21,6 +22,36 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const publicPaths = ['/','/login', '/signup', '/forgot-password', '/Terms', '/Privacy', '/activate', '/users/oauth/callback'];
const isPublicPath = publicPaths.includes(pathname);
/**
* GA属性
* userData结构: {"id":"8f61686734d340d0820f0fe980368629","userId":"8f61686734d340d0820f0fe980368629","username":"Orange","email":"moviflow68@test.com","isActive":1,"authType":"LOCAL","lastLogin":"2025-09-28T16:28:51.870731"}
*/
const setUserAnalyticsProperties = (userData: any) => {
if (!userData || !userData.id) {
return;
}
try {
setUserProperties(userData.id, {
// 基础用户信息
user_id: userData.id,
email: userData.email,
username: userData.username,
// 认证信息
auth_type: userData.authType || 'LOCAL',
is_active: userData.isActive || 1,
// 登录信息
last_login: userData.lastLogin || new Date().toISOString(),
// 页面信息
current_page: pathname,
// 用户状态
user_status: userData.isActive === 1 ? 'active' : 'inactive',
// 会话信息
session_id: `${userData.id}_${Date.now()}`
});
} catch (error) {}
};
useEffect(() => {
const verifyAuth = async () => {
// 如果是公共页面,不需要鉴权
@ -41,6 +72,9 @@ export default function AuthGuard({ children }: AuthGuardProps) {
const user = await getUserProfile();
if (user) {
setIsAuthorized(true);
// 设置用户GA属性页面首次加载时
setUserAnalyticsProperties(user);
} else {
router.push('/login');
}
@ -49,8 +83,16 @@ export default function AuthGuard({ children }: AuthGuardProps) {
if(errorCode.message == 401||errorCode.message == 502){
router.push('/login');
clearAuthData();
} else {
// 如果API调用失败但不是认证错误尝试使用本地存储的用户数据
const localUser = getCurrentUser();
if (localUser && localUser.id) {
console.log('API调用失败使用本地用户数据设置GA属性');
setUserAnalyticsProperties(localUser);
setIsAuthorized(true);
}
}
errorHandle(errorCode.message)
errorHandle(errorCode.message)
} finally {
setIsLoading(false);
}

View File

@ -0,0 +1,63 @@
import React from 'react';
/**
*
*
*/
export interface FooterProps {
/** 版权年份,默认为当前年份 */
year?: number;
/** 公司名称,默认为 "MovieFlow" */
companyName?: string;
/** 自定义版权文本 */
customText?: string;
/** 额外的CSS类名 */
className?: string;
/** 是否显示邮箱链接 */
showEmailLink?: boolean;
/** 邮箱地址,默认为 "support@movieflow.ai" */
emailAddress?: string;
}
/**
*
* @param props -
* @returns JSX元素
*/
const Footer: React.FC<FooterProps> = ({
year = new Date().getFullYear(),
companyName = 'MovieFlow',
customText,
className = '',
showEmailLink = false,
emailAddress = 'support@movieflow.ai'
}) => {
const copyrightText = customText || `© ${year} ${companyName}. All rights reserved.`;
return (
<div
data-alt="footer-component"
className={`home-module6 flex justify-center items-center w-full h-min text-white/50 text-lg bg-black snap-start px-4 ${className}`}
>
{/* 左侧版权信息 */}
<div className="text-center">
{copyrightText}
</div>
{/* 右侧邮箱链接 */}
{showEmailLink && (
<div className="flex-shrink-0 ml-4">
<a
href={`mailto:${emailAddress}`}
className="text-custom-blue hover:text-white/80 transition-colors duration-200 underline decoration-white/30 hover:decoration-white/60"
data-alt="support-email-link"
>
Contact Us
</a>
</div>
)}
</div>
);
};
export default Footer;

View File

@ -0,0 +1,18 @@
/**
*
*/
export interface FooterProps {
/** 版权年份,默认为当前年份 */
year?: number;
/** 公司名称,默认为 "MovieFlow" */
companyName?: string;
/** 自定义版权文本 */
customText?: string;
/** 额外的CSS类名 */
className?: string;
/** 是否显示邮箱链接 */
showEmailLink?: boolean;
/** 邮箱地址,默认为 "support@movieflow.ai" */
emailAddress?: string;
}

View File

@ -0,0 +1,42 @@
.backdrop_backdrop {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 58;
display: grid;
height: 4rem;
pointer-events: none;
}
.backdrop_backdrop:after {
content: "";
background: linear-gradient(180deg, rgba(0, 0, 0, .2) 60%, transparent);
}
.backdrop_backdrop:after, .backdrop_backdrop .backdrop_blur {
grid-area: 1 / 1;
transition: opacity .2s linear;
}
.backdrop_backdrop>.backdrop_blur:first-child {
backdrop-filter: blur(1px);
mask: linear-gradient(0deg, transparent, #000 8%);
}
.backdrop_backdrop>.backdrop_blur:nth-child(2) {
backdrop-filter: blur(4px);
mask: linear-gradient(0deg, transparent 8%, #000 16%);
}
.backdrop_backdrop>.backdrop_blur:nth-child(3) {
backdrop-filter: blur(8px);
mask: linear-gradient(0deg, transparent 16%, #000 24%);
}
.backdrop_backdrop>.backdrop_blur:nth-child(4) {
backdrop-filter: blur(16px);
mask: linear-gradient(0deg, transparent 24%, #000 36%);
}
.backdrop_backdrop>.backdrop_blur:nth-child(5) {
backdrop-filter: blur(24px);
mask: linear-gradient(0deg, transparent 36%, #000 48%);
}
.backdrop_backdrop>.backdrop_blur:nth-child(6) {
backdrop-filter: blur(32px);
mask: linear-gradient(0deg, transparent 48%, #000 60%);
}

View File

@ -0,0 +1,493 @@
"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, createPortalSession, redirectToPortal } from '@/lib/stripe';
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 <= 0) return;
await handleBuyTokens(amount);
setCustomAmount("");
};
const handleSignin = () => {
setIsSigninModalOpen(true);
};
const handleManageSubscription = async () => {
if (!user?.id) return;
setIsManagingSubscription(true);
try {
const response = await createPortalSession({
user_id: String(user.id),
return_url: window.location.origin + '/dashboard',
});
if (response.successful && response.data?.portal_url) {
redirectToPortal(response.data.portal_url);
}
} 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}
bodyStyle={{ padding: 0 }}
maskClosable
// 64px 顶栏高度 + 8px 安全间距
maskStyle={{ position: 'absolute', top: '3.5rem', height: 'calc(100dvh - 3.5rem)', backgroundColor: 'transparent' }}
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>
</>
);
}

View File

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { Sidebar } from './sidebar';
import { TopBar } from './top-bar';
import { useDeviceType } from '@/hooks/useDeviceType';
import H5TopBar from '@/components/layout/H5TopBar';
interface DashboardLayoutProps {
children: React.ReactNode;
@ -58,15 +59,20 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-background">
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
{isMobile || isTablet ? (
<H5TopBar />
) : (
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
)}
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
<div
className={`top-[4rem] fixed right-0 bottom-0 px-4 ${getMobileContainerClasses()}`}
className={`fixed right-0 bottom-0 px-4 z-[60] ${getMobileContainerClasses()}`}
style={{
...getLayoutStyles(),
top: (isMobile || isTablet) ? '3.5rem' : '4rem',
// 移动端使用动态高度计算
height: (isMobile || isTablet)
? 'calc(100dvh - 4rem)'
? 'calc(100dvh - 3.5rem)'
: 'calc(100vh - 4rem)'
}}
>

View File

@ -1,44 +1,19 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/public/lib/utils';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { GradientText } from '@/components/ui/gradient-text';
import {
Home,
FolderOpen,
Users,
Type,
Image,
History,
ChevronLeft,
ChevronRight,
Video,
PanelsLeftBottom,
ArrowLeftToLine,
BookHeart,
PanelRightClose,
Gift
PanelRightClose
} from 'lucide-react';
import { navigationItems } from './type';
interface SidebarProps {
collapsed: boolean;
onToggle: (collapsed: boolean) => void;
}
const navigationItems = [
{
title: 'Main',
items: [
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
{ name: 'Share', href: '/share', icon: Gift },
],
}
];
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const pathname = usePathname();

View File

@ -7,7 +7,7 @@ import { Coins, Trophy, HelpCircle } from "lucide-react"
import { getSigninStatus, performSignin, SigninData } from "@/api/signin"
export default function SigninPage() {
export default function SigninPage({ onSuccess }: { onSuccess?: () => void } = {}) {
const [signinData, setSigninData] = useState<SigninData>({
has_signin: false,
credits: 0
@ -50,6 +50,8 @@ export default function SigninPage() {
if (response.successful) {
// Refresh status after successful signin
await fetchSigninStatus()
// Notify parent to refresh credits
try { onSuccess && onSuccess() } catch {}
}
} catch (error) {
console.error('Signin failed:', error)

22
components/layout/type.ts Normal file
View File

@ -0,0 +1,22 @@
import { BookHeart, Gift } from "lucide-react";
interface NavigationItem {
name: string;
href: string;
icon: any;
}
interface Navigations {
title: string;
items: NavigationItem[];
}
export const navigationItems: Navigations[] = [
{
title: 'Main',
items: [
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
{ name: 'Share', href: '/share', icon: Gift },
],
}
];

View File

@ -8,6 +8,7 @@ import './style/create-to-video2.css';
import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
import cover_image1 from '@/public/assets/cover_image3.jpg';
import cover_image2 from '@/public/assets/cover_image_shu.jpg';
import { motion } from 'framer-motion';
import { Tooltip, Button } from 'antd';
import { downloadVideo, getFirstFrame } from '@/utils/tools';
@ -309,7 +310,7 @@ export default function CreateToVideo2() {
<div
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
style={{
backgroundImage: `url(${cover_image1.src})`,
backgroundImage: `url(${project.aspect_ratio === 'VIDEO_ASPECT_RATIO_PORTRAIT' ? cover_image2.src : cover_image1.src})`,
}}
data-alt="cover-image"
/>

View File

@ -20,8 +20,10 @@ import LazyLoad from "react-lazyload";
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
import VideoCoverflow from "@/components/ui/VideoCoverflow";
import { fetchTabsByCode, HomeTabItem } from "@/api/serversetting";
import H5TopBar from "@/components/layout/H5TopBar";
import { useCallbackModal } from "@/app/layout";
import { useDeviceType } from "@/hooks/useDeviceType";
import Footer from "@/components/common/Footer";
/** 视频预加载系统 - 后台静默运行 */
function useVideoPreloader() {
@ -241,26 +243,9 @@ export function HomePage2() {
</button>
))}
</div>
{/* 移动端开关移至 TopBar保留占位对齐 */}
{/* 移动端交由 H5TopBar 控制 */}
<span className="md:hidden" data-alt="mobile-menu-toggle-placeholder"></span>
</div>
{/* 移动端下拉(仅三个项) */}
{menuOpen && (
<div data-alt="mobile-menu" className="md:hidden bg-black/80 backdrop-blur-md border-b border-white/10 px-4 py-2 text-white/90 text-sm">
<div className="grid grid-cols-1 gap-1">
{tabsToRender.map((tab) => (
<button
key={tab.title}
data-alt={`m-nav-${tab.title.toLowerCase()}`}
className="text-center py-2"
onClick={() => scrollToSection(tab.title.toLowerCase() as any)}
>
{tab.title}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
@ -268,7 +253,8 @@ export function HomePage2() {
return (
<div className="w-full h-screen overflow-y-auto" id="home-page" ref={containerRef} style={{ paddingBottom: `2rem` }}>
<NavBar />
{/* 移动端顶部导航(抽屉式) */}
{ isMobile ? (<H5TopBar onSelectHomeTab={(key) => scrollToSection(key as any)} />) : (<NavBar />) }
<HomeModule1 />
<LazyLoad once>
<HomeModule2 />
@ -286,7 +272,7 @@ export function HomePage2() {
<HomeModule4 />
</LazyLoad>
<HomeModule5 />
<HomeModule6 />
<Footer showEmailLink={true} />
</div>
);
}
@ -312,8 +298,8 @@ function HomeModule1() {
>
<LazyLoad once>
<video
src="https://cdn.qikongjian.com/1756549479451_ltrtoz.mp4"
poster="https://cdn.qikongjian.com/1756549479451_ltrtoz.mp4?vframe/jpg/offset/1"
src="https://cdn.qikongjian.com/videos/1759064057657_cu0po2.mp4"
poster="https://cdn.qikongjian.com/videos/1759064057657_cu0po2.mp4?vframe/jpg/offset/1"
autoPlay
loop
muted
@ -1616,10 +1602,3 @@ function HomeModule5() {
);
}
function HomeModule6() {
return (
<div className="home-module6 flex justify-center items-center w-full h-min text-white/50 text-lg bg-black snap-start">
© 2025 MovieFlow. All rights reserved.
</div>
);
}

View File

@ -10,6 +10,7 @@ import { GradientText } from "@/components/ui/gradient-text";
import { GoogleLoginButton } from "@/components/ui/google-login-button";
import { Eye, EyeOff } from "lucide-react";
import { isGoogleLoginEnabled } from "@/lib/server-config";
import Footer from "@/components/common/Footer";
export default function Login() {
const [email, setEmail] = useState("");
@ -314,6 +315,9 @@ export default function Login() {
</form>
</div>
</div>
{/* 页脚 */}
<Footer className="fixed bottom-0" showEmailLink={true} />
</div>
);
}

View File

@ -191,9 +191,10 @@ const UsageView: React.FC = () => {
}, []);
return (
<div data-alt="usage-view-container" className="mx-auto max-w-5xl p-6">
<div data-alt="header" className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Credit Usage Details</h2>
<div data-alt="usage-view-container" className="mx-auto max-w-5xl h-full flex flex-col p-4 sm:p-6 pb-[max(5rem,env(safe-area-inset-bottom))]">
<div data-alt="top-meta" className="sticky top-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 pt-2 pb-3 bg-black/60 backdrop-blur supports-[backdrop-filter]:bg-black/40">
<div data-alt="header" className="mb-3 flex items-center justify-between">
<h2 className="text-base sm:text-xl font-semibold text-white">Credit Usage Details</h2>
<div data-alt="period-switch" className="inline-flex rounded-lg bg-white/5 p-1">
{([7, 30, 90] as PeriodDays[]).map((d) => (
<button
@ -202,7 +203,7 @@ const UsageView: React.FC = () => {
data-alt={`period-${d}`}
onClick={() => handleChangeDays(d)}
className={
`px-3 py-1.5 text-sm rounded-md transition-colors ` +
`px-2 py-1 text-xs sm:px-3 sm:py-1.5 sm:text-sm rounded-md transition-colors ` +
(days === d
? "bg-[#C039F6] text-white"
: "text-white/80 hover:bg-white/10")
@ -212,34 +213,34 @@ const UsageView: React.FC = () => {
</button>
))}
</div>
</div>
<div data-alt="meta" className="text-xs sm:text-sm text-white/70">
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
</div>
</div>
<div data-alt="meta" className="mb-3 text-sm text-white/70">
<span data-alt="meta-period">Period: {periodLabel || "-"} days</span>
</div>
<div data-alt="table-wrapper" className="overflow-hidden rounded-lg border border-white/10">
<table data-alt="table" className="min-w-full table-fixed">
<div data-alt="table-wrapper" className="flex-1 min-h-0 overflow-auto rounded-lg border border-white/10">
<table data-alt="table" className="min-w-[32rem] sm:min-w-full table-auto">
<thead className="bg-white/5">
<tr>
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Kind</th>
<th className="w-1/4 px-4 py-2 text-right text-sm font-medium text-white">Credits</th>
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">From</th>
<th className="w-1/4 px-4 py-2 text-left text-sm font-medium text-white">Date</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Kind</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Credits</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">From</th>
<th className="px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm font-medium text-white">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td className="px-4 py-3 text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" colSpan={4} data-alt="row-loading">Loading...</td>
</tr>
) : error ? (
<tr>
<td className="px-4 py-3 text-white/70" data-alt="row-error" colSpan={4}>-</td>
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-error" colSpan={4}>-</td>
</tr>
) : items.length === 0 ? (
<tr>
<td className="px-4 py-3 text-white/70" data-alt="row-empty" colSpan={4}>-</td>
<td className="px-2 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm text-white/70" data-alt="row-empty" colSpan={4}>-</td>
</tr>
) : (
items.map((it, idx) => {
@ -248,7 +249,7 @@ const UsageView: React.FC = () => {
return (
<React.Fragment key={key}>
<tr>
<td className="px-4 py-2 text-white/90" data-alt="cell-transaction-type">
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-transaction-type">
<div data-alt="type-cell" className="flex items-center gap-2">
<span data-alt="type-text">{it?.transaction_type || '-'}</span>
{it?.project_info ? (
@ -272,47 +273,47 @@ const UsageView: React.FC = () => {
) : null}
</div>
</td>
<td className={`px-4 py-2 text-right ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
<td className={`px-2 py-1 text-left text-xs sm:px-4 sm:py-2 sm:text-sm ${getAmountColorClass(it?.change_type)}`} data-alt="cell-amount">
{Number.isFinite(it?.amount as number)
? `${it.change_type === 'INCREASE' ? '+' : it.change_type === 'DECREASE' ? '-' : ''}${it.amount}`
: '-'}
</td>
<td className="px-4 py-2 text-white/90" data-alt="cell-source-type">
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-source-type">
{formatSource(it?.source_type)}
</td>
<td className="px-4 py-2 text-white/90" data-alt="cell-created-at">
<td className="px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm text-white/90" data-alt="cell-created-at">
{it?.created_at ? dateFormatter.format(new Date(it.created_at)) : '-'}
</td>
</tr>
{isExpanded && it.project_info && (
<tr data-alt="row-details">
<td colSpan={4} className="bg-white/5 px-4 py-3">
<div data-alt="project-summary" className="mb-2 text-sm text-white/90">
<td colSpan={4} className="bg-white/5 px-3 py-2 sm:px-4 sm:py-3">
<div data-alt="project-summary" className="mb-2 text-xs sm:text-sm text-white/90">
<div data-alt="project-name">Project: {it.project_info.project_name || '-'}</div>
<div data-alt="project-id" className="text-white/60">ID: {it.project_info.project_id || '-'}</div>
</div>
<div data-alt="videos-table-wrapper" className="overflow-hidden rounded-md border border-white/10">
<table data-alt="videos-table" className="min-w-full table-fixed">
<table data-alt="videos-table" className="min-w-[28rem] sm:min-w-full table-auto">
<tbody className="divide-y divide-white/10">
{(it.project_info.videos || []).length === 0 ? (
<tr>
<td className="px-3 py-2 text-white/70" colSpan={4} data-alt="videos-empty">-</td>
<td className="px-3 py-2 text-xs sm:text-sm text-white/70" colSpan={4} data-alt="videos-empty">-</td>
</tr>
) : (
it.project_info.videos.map((v, vIdx) => (
<tr key={`${v.created_at}-${vIdx}`}>
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-name">
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-name">
{v.video_name && v.video_name.trim() ? v.video_name : `video${vIdx + 1}`}
</td>
<td className={`w-1/4 px-3 py-2 text-right ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
<td className={`px-2 py-1 text-left text-xs sm:px-3 sm:py-2 sm:text-sm ${getAmountColorClass(v.change_type)}`} data-alt="video-amount">
{Number.isFinite(v.amount as number)
? `${v.change_type === 'INCREASE' ? '+' : v.change_type === 'DECREASE' ? '-' : ''}${v.amount}`
: '-'}
</td>
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-source">
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-source">
{formatSource(v.source_type)}
</td>
<td className="w-1/4 px-3 py-2 text-white/90" data-alt="video-created-at">
<td className="px-2 py-1 text-xs sm:px-3 sm:py-2 sm:text-sm text-white/90" data-alt="video-created-at">
{v.created_at ? dateFormatter.format(new Date(v.created_at)) : '-'}
</td>
</tr>
@ -332,7 +333,7 @@ const UsageView: React.FC = () => {
</table>
</div>
<div data-alt="pagination" className="mt-4 flex items-center justify-between text-sm text-white/80">
<div data-alt="pagination" className="sticky bottom-[max(1rem,env(safe-area-inset-bottom))] z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 mt-4 mb-[max(4rem,env(safe-area-inset-bottom))] flex items-center justify-between text-xs sm:text-sm text-white/80 bg-black/60 backdrop-blur supports-[backdrop-filter]:bg-black/40">
<div data-alt="total-info">
Total {Number.isFinite(total) ? total : 0}
</div>
@ -342,20 +343,20 @@ const UsageView: React.FC = () => {
onClick={handlePrev}
disabled={!canPrev}
className={
"rounded-md px-3 py-1.5 transition-colors " +
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
(canPrev ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
}
data-alt="prev-page"
>
Previous
</button>
<span data-alt="page-indicator" className="px-1 py-1.5">Page {page}</span>
<span data-alt="page-indicator" className="px-1 py-1 sm:py-1.5">Page {page}</span>
<button
type="button"
onClick={handleNext}
disabled={!canNext}
className={
"rounded-md px-3 py-1.5 transition-colors " +
"rounded-md px-2 py-1 sm:px-3 sm:py-1.5 transition-colors " +
(canNext ? "bg-white/10 hover:bg-white/20" : "bg-white/5 text-white/40 cursor-not-allowed")
}
data-alt="next-page"

View File

@ -668,9 +668,8 @@ Please process this video editing request.`;
getContainer={false}
autoFocus={false}
mask={false}
zIndex={60}
rootClassName="outline-none"
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl max-h-[90vh]"
className="bg-transparent max-h-[100vh]"
style={{
backgroundColor: 'transparent',
...(isMobile
@ -682,8 +681,8 @@ Please process this video editing request.`;
body: {
backgroundColor: 'transparent',
padding: 0,
maxHeight: '100vh',
overflow: 'auto',
maxHeight: isMobile ? 'calc(100vh - 5.5rem)' : 'calc(100vh - 4rem)',
overflow: 'hidden',
}
}}
onClose={() => setIsSmartChatBoxOpen(false)}

View File

@ -130,7 +130,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
<>
{/* 背景遮罩 */}
<motion.div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-50"
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[9998]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@ -140,7 +140,7 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentL
{/* 弹窗内容 */}
<motion.div
className="fixed inset-0 z-[61] flex items-center justify-center"
className="fixed inset-0 z-[9999] flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}

194
hooks/useAnalytics.ts Normal file
View File

@ -0,0 +1,194 @@
/**
* Google Analytics Hook
* 便GA事件跟踪功能
*/
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { trackPageView, trackEvent, isGAAvailable, setUserProperties } from '@/utils/analytics';
/**
* 访Hook
* @param pageTitle -
* @param customParams -
*/
export const usePageTracking = (
pageTitle?: string,
customParams?: Record<string, any>
) => {
const router = useRouter();
useEffect(() => {
if (isGAAvailable()) {
const currentPath = window.location.pathname;
trackPageView(currentPath, pageTitle, { custom_parameters: customParams });
}
}, [router, pageTitle, customParams]);
};
/**
* Hook
*/
export const useEventTracking = () => {
const trackUserAction = (
action: string,
category: string = 'user',
label?: string,
value?: number,
customParams?: Record<string, any>
) => {
trackEvent(action, {
event_category: category,
event_label: label,
value: value,
custom_parameters: customParams,
});
};
const trackButtonClick = (buttonName: string, location?: string) => {
trackUserAction('button_click', 'interaction', buttonName, undefined, {
button_name: buttonName,
location: location,
});
};
const trackFormSubmit = (formName: string, success: boolean = true) => {
trackUserAction('form_submit', 'form', formName, undefined, {
form_name: formName,
success: success,
});
};
const trackNavigation = (from: string, to: string) => {
trackUserAction('navigation', 'user', `${from} -> ${to}`, undefined, {
from_page: from,
to_page: to,
});
};
return {
trackUserAction,
trackButtonClick,
trackFormSubmit,
trackNavigation,
};
};
/**
* Hook
*/
export const useVideoTracking = () => {
const trackVideoCreation = (templateType: string, aspectRatio?: string) => {
trackEvent('video_creation_start', {
event_category: 'video',
event_label: templateType,
custom_parameters: {
template_type: templateType,
aspect_ratio: aspectRatio,
},
});
};
const trackVideoGeneration = (duration: number, templateType: string) => {
trackEvent('video_generation_complete', {
event_category: 'video',
value: duration,
custom_parameters: {
template_type: templateType,
video_duration: duration,
},
});
};
const trackVideoDownload = (videoId: string, format: string) => {
trackEvent('video_download', {
event_category: 'video',
event_label: format,
custom_parameters: {
video_id: videoId,
format: format,
},
});
};
const trackVideoShare = (videoId: string, platform: string) => {
trackEvent('video_share', {
event_category: 'video',
event_label: platform,
custom_parameters: {
video_id: videoId,
platform: platform,
},
});
};
return {
trackVideoCreation,
trackVideoGeneration,
trackVideoDownload,
trackVideoShare,
};
};
/**
* Hook
*/
export const usePaymentTracking = () => {
const trackPaymentStart = (paymentType: string, amount: number, currency: string = 'USD') => {
trackEvent('payment_start', {
event_category: 'ecommerce',
value: amount,
custom_parameters: {
payment_type: paymentType,
currency: currency,
},
});
};
const trackPaymentComplete = (paymentType: string, amount: number, currency: string = 'USD') => {
trackEvent('purchase', {
event_category: 'ecommerce',
value: amount,
custom_parameters: {
payment_type: paymentType,
currency: currency,
},
});
};
const trackPaymentFailed = (paymentType: string, errorReason: string) => {
trackEvent('payment_failed', {
event_category: 'ecommerce',
event_label: errorReason,
custom_parameters: {
payment_type: paymentType,
error_reason: errorReason,
},
});
};
return {
trackPaymentStart,
trackPaymentComplete,
trackPaymentFailed,
};
};
/**
* Hook
*/
export const useUserProperties = () => {
const setUserAnalyticsProperties = (
userId: string,
userProperties: Record<string, any>
) => {
setUserProperties(userId, userProperties);
};
return {
setUserAnalyticsProperties,
};
};

View File

@ -0,0 +1,67 @@
/**
* Hook
* GA属性
*/
import { useEffect } from 'react';
import { getCurrentUser, isAuthenticated } from '@/lib/auth';
import { setUserProperties } from '@/utils/analytics';
/**
* GA属性
*/
export const useAppStartupAnalytics = () => {
useEffect(() => {
const initializeUserAnalytics = () => {
// 检查用户是否已认证
if (!isAuthenticated()) {
console.log('用户未认证跳过GA属性设置');
return;
}
// 获取本地存储的用户数据
const currentUser = getCurrentUser();
if (!currentUser || !currentUser.id) {
console.log('本地用户数据不存在跳过GA属性设置');
return;
}
try {
// 设置应用启动时的用户属性
// 基于实际userData结构: {"id":"8f61686734d340d0820f0fe980368629","userId":"8f61686734d340d0820f0fe980368629","username":"Orange","email":"moviflow68@test.com","isActive":1,"authType":"LOCAL","lastLogin":"2025-09-28T16:28:51.870731"}
setUserProperties(currentUser.id, {
// 基础用户信息
user_id: currentUser.id,
email: currentUser.email,
username: currentUser.username,
// 认证信息
auth_type: currentUser.authType || 'LOCAL',
is_active: currentUser.isActive || 1,
// 应用启动信息
last_login: currentUser.lastLogin || new Date().toISOString(),
// 用户状态
user_status: currentUser.isActive === 1 ? 'active' : 'inactive',
// 设备信息
user_agent: navigator.userAgent,
screen_resolution: `${screen.width}x${screen.height}`,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
// 会话信息
session_id: `${currentUser.id}_${Date.now()}`,
});
} catch (error) {
console.error('❌ 应用启动时GA用户属性设置失败:', error);
}
};
// 延迟执行,确保应用完全加载
const timer = setTimeout(initializeUserAnalytics, 1000);
return () => clearTimeout(timer);
}, []);
};

View File

@ -7,6 +7,7 @@ import type {
EmailConflictData,
OAuthState
} from '@/app/types/google-oauth';
import { setUserProperties } from '@/utils/analytics';
// API配置
//const JAVA_BASE_URL = 'http://192.168.120.36:8080';
@ -109,6 +110,25 @@ export const getCurrentUser = () => {
export const setUser = (user: any) => {
if (typeof window === 'undefined') return;
localStorage.setItem(USER_KEY, JSON.stringify(user));
// 设置详细的GA用户属性
if (user && user.id) {
setUserProperties(user.id, {
// 基础用户信息
user_id: user.id,
email: user.email,
username: user.username,
// 认证信息
auth_type: user.authType || 'LOCAL',
is_active: user.isActive || 1,
// 用户状态
user_status: user.isActive === 1 ? 'active' : 'inactive',
// 登录信息
last_login: user.lastLogin || new Date().toISOString(),
// 会话信息
session_id: `${user.id}_${Date.now()}`
});
}
};
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

334
utils/analytics.ts Normal file
View File

@ -0,0 +1,334 @@
/**
* Google Analytics 4
* 访
*/
// 扩展全局Window接口
declare global {
interface Window {
gtag: (...args: any[]) => void;
dataLayer: any[];
}
}
/**
* GA4事件参数类型定义
*/
export interface GAEventParameters {
event_category?: string;
event_label?: string;
value?: number;
custom_parameters?: Record<string, any>;
}
/**
* 访
*/
export interface GAPageViewParameters {
page_title?: string;
page_location?: string;
custom_parameters?: Record<string, any>;
}
/**
* / [object Object]
*/
const normalizeEventParams = (
params: Record<string, any>
): Record<string, string | number | boolean> => {
const result: Record<string, string | number | boolean> = {};
const assignPrimitive = (key: string, value: any) => {
if (value === undefined) return;
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
result[key] = value;
} else if (value === null) {
result[key] = 'null';
} else {
try {
result[key] = JSON.stringify(value);
} catch (_) {
result[key] = String(value);
}
}
};
for (const [key, value] of Object.entries(params || {})) {
if (key === 'custom_parameters' && value && typeof value === 'object') {
for (const [ck, cv] of Object.entries(value)) {
assignPrimitive(ck, cv);
}
continue;
}
assignPrimitive(key, value);
}
return result;
};
/**
* GA是否可用
*/
export const isGAAvailable = (): boolean => {
return typeof window !== 'undefined' &&
typeof window.gtag === 'function' &&
process.env.NEXT_PUBLIC_GA_ENABLED === 'true';
};
/**
* GA测量ID
*/
export const getGAMeasurementId = (): string => {
return process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || 'G-4BDXV6TWF4';
};
/**
*
* @param eventName -
* @param parameters -
*/
export const trackEvent = (
eventName: string,
parameters?: GAEventParameters
): void => {
if (!isGAAvailable()) {
console.warn('Google Analytics not available');
return;
}
try {
const eventParamsRaw = {
event_category: parameters?.event_category || 'general',
event_label: parameters?.event_label,
value: parameters?.value,
...parameters?.custom_parameters,
};
const eventParams = normalizeEventParams(eventParamsRaw);
window.gtag('event', eventName, eventParams);
// 开发环境下打印日志
if (process.env.NODE_ENV === 'development') {
console.log('GA Event:', eventName, eventParams);
}
} catch (error) {
console.error('Error tracking GA event:', error);
}
};
/**
* 访
* @param pagePath -
* @param pageTitle -
* @param parameters -
*/
export const trackPageView = (
pagePath: string,
pageTitle?: string,
parameters?: GAPageViewParameters
): void => {
if (!isGAAvailable()) {
console.warn('Google Analytics not available');
return;
}
try {
const pageParamsRaw = {
page_path: pagePath,
page_title: pageTitle,
page_location: parameters?.page_location || window.location.href,
...parameters?.custom_parameters,
};
const pageParams = normalizeEventParams(pageParamsRaw);
window.gtag('config', getGAMeasurementId(), pageParams);
// 开发环境下打印日志
if (process.env.NODE_ENV === 'development') {
console.log('GA Page View:', pagePath, pageParams);
}
} catch (error) {
console.error('Error tracking GA page view:', error);
}
};
/**
*
* @param method - (email, google, etc.)
*/
export const trackUserRegistration = (method: string): void => {
trackEvent('user_registration', {
event_category: 'user',
event_label: method,
custom_parameters: {
registration_method: method,
},
});
};
/**
*
* @param method - (email, google, etc.)
*/
export const trackUserLogin = (method: string): void => {
trackEvent('user_login', {
event_category: 'user',
event_label: method,
custom_parameters: {
login_method: method,
},
});
};
/**
*
* @param templateType -
* @param aspectRatio -
*/
export const trackVideoCreationStart = (
templateType: string,
aspectRatio?: string
): void => {
trackEvent('video_creation_start', {
event_category: 'video',
event_label: templateType,
custom_parameters: {
template_type: templateType,
aspect_ratio: aspectRatio,
},
});
};
/**
*
* @param duration -
* @param templateType -
*/
export const trackVideoGenerationComplete = (
duration: number,
templateType: string
): void => {
trackEvent('video_generation_complete', {
event_category: 'video',
value: duration,
custom_parameters: {
template_type: templateType,
video_duration: duration,
},
});
};
/**
*
* @param paymentType - (subscription, token)
* @param amount -
* @param currency -
*/
export const trackPayment = (
paymentType: string,
amount: number,
currency: string = 'USD'
): void => {
trackEvent('purchase', {
event_category: 'ecommerce',
value: amount,
custom_parameters: {
payment_type: paymentType,
currency: currency,
},
});
};
/**
*
* @param templateId - ID
* @param templateName -
*/
export const trackTemplateSelection = (
templateId: string,
templateName: string
): void => {
trackEvent('template_selection', {
event_category: 'template',
event_label: templateName,
custom_parameters: {
template_id: templateId,
template_name: templateName,
},
});
};
/**
* 使
* @param featureName -
* @param action -
*/
export const trackFeatureUsage = (
featureName: string,
action: string
): void => {
trackEvent('feature_usage', {
event_category: 'feature',
event_label: featureName,
custom_parameters: {
feature_name: featureName,
action: action,
},
});
};
/**
*
* @param errorType -
* @param errorMessage -
* @param errorLocation -
*/
export const trackError = (
errorType: string,
errorMessage: string,
errorLocation?: string
): void => {
trackEvent('error', {
event_category: 'error',
event_label: errorType,
custom_parameters: {
error_type: errorType,
error_message: errorMessage,
error_location: errorLocation,
},
});
};
/**
*
* @param userId - ID
* @param userProperties -
*/
export const setUserProperties = (
userId: string,
userProperties: Record<string, any>
): void => {
if (!isGAAvailable()) {
console.warn('Google Analytics not available');
return;
}
try {
// GA4 推荐:通过 config 设置 user_id通过 set user_properties 设置用户属性
window.gtag('config', getGAMeasurementId(), {
user_id: userId,
});
window.gtag('set', 'user_properties', normalizeEventParams(userProperties));
} catch (error) {
console.error('Error setting user properties:', error);
}
};