This commit is contained in:
Zixin Zhou 2025-08-28 17:06:13 +08:00
commit dc10dc6ed9
21 changed files with 1477 additions and 521 deletions

View File

@ -33,8 +33,10 @@ export default function RootLayout({
}}
>
<Providers>
<ScreenAdapter />
{children}
{/* <ScreenAdapter /> */}
<div id="app" className='h-full w-full'>
{children}
</div>
</Providers>
</ConfigProvider>
</body>

197
app/payCallback/page.tsx Normal file
View File

@ -0,0 +1,197 @@
'use client'
import React, { useEffect, useState } from 'react'
import { Result, Button, Spin, Card } from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import { useRouter, useSearchParams } from 'next/navigation'
import { getCheckoutSessionStatus, PaymentStatusResponse } from '@/lib/stripe'
/**
*
*/
enum PaymentStatus {
LOADING = 'loading',
SUCCESS = 'success',
FAILED = 'failed'
}
/**
*
* Stripe Checkout支付完成后的状态展示和用户操作
*/
export default function PayCallbackPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [paymentStatus, setPaymentStatus] = useState<PaymentStatus>(PaymentStatus.LOADING)
const [paymentInfo, setPaymentInfo] = useState<any>(null)
const callBackUrl = localStorage.getItem('callBackUrl') || '/'
/**
* Stripe Checkout Session支付状态
*/
const fetchPaymentStatus = async () => {
try {
// 从URL参数获取session_id和user_id
const sessionId = searchParams.get('session_id')
const userId = searchParams.get('user_id')
if (!sessionId || !userId) {
throw new Error('缺少必要的参数: session_id 或 user_id')
}
// 调用真实的Stripe API获取支付状态
const response = await getCheckoutSessionStatus(sessionId, userId)
if (response.successful && response.data) {
const { payment_status, biz_order_no, pay_time, subscription } = response.data
if (payment_status === 'success') {
setPaymentStatus(PaymentStatus.SUCCESS)
setPaymentInfo({
orderId: biz_order_no,
sessionId,
paymentTime: pay_time,
subscription: subscription ? {
planName: subscription.plan_name,
planDisplayName: subscription.plan_display_name,
status: subscription.status,
currentPeriodEnd: subscription.current_period_end
} : null
})
} else if (payment_status === 'fail') {
setPaymentStatus(PaymentStatus.FAILED)
setPaymentInfo({
orderId: biz_order_no,
sessionId,
errorMessage: '支付处理失败,请重试'
})
} else {
// pending状态继续等待
setTimeout(fetchPaymentStatus, 2000)
}
} else {
throw new Error(response.message || '获取支付状态失败')
}
} catch (error) {
console.error('获取支付状态失败:', error)
setPaymentStatus(PaymentStatus.FAILED)
setPaymentInfo({
errorMessage: error instanceof Error ? error.message : '网络错误,无法获取支付状态'
})
}
}
/**
*
*/
const handleGoBack = () => {
router.push(callBackUrl)
}
/**
*
*/
const handleRetryPayment = () => {
router.push('/pricing')
}
useEffect(() => {
fetchPaymentStatus()
}, [])
// 加载状态
if (paymentStatus === PaymentStatus.LOADING) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="w-96 text-center bg-black border border-white/20 rounded-lg p-8">
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: 'white' }} spin />}
size="large"
/>
<div className="mt-6 text-lg text-white">...</div>
<div className="mt-2 text-sm text-white/70"></div>
</div>
</div>
)
}
// 支付成功状态
if (paymentStatus === PaymentStatus.SUCCESS) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="w-[32rem] text-center bg-black border border-white/20 rounded-lg p-8">
<div className="mb-6">
<CheckCircleOutlined className="text-white text-6xl mb-4" />
<h1 className="text-white text-2xl font-bold mb-2"></h1>
<p className="text-white/70 text-base">{`订单号: ${paymentInfo?.orderId || 'N/A'}`}</p>
</div>
<div className="text-center text-white/70 mb-8 space-y-2">
{paymentInfo?.subscription && (
<>
<p>: {paymentInfo.subscription.planDisplayName}</p>
<p>: {paymentInfo.subscription.status}</p>
{paymentInfo.subscription.currentPeriodEnd && (
<p>: {new Date(paymentInfo.subscription.currentPeriodEnd).toLocaleString()}</p>
)}
</>
)}
{paymentInfo?.paymentTime && (
<p>: {new Date(paymentInfo.paymentTime).toLocaleString()}</p>
)}
<p></p>
</div>
<Button
type="primary"
size="large"
onClick={handleGoBack}
data-alt="back-to-previous-page-button"
className="w-full h-12 bg-white text-black hover:bg-white/90 border border-white/20"
>
</Button>
</div>
</div>
)
}
// 支付失败状态
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="w-[32rem] text-center bg-black border border-white/20 rounded-lg p-8">
<div className="mb-6">
<CloseCircleOutlined className="text-white text-6xl mb-4" />
<h1 className="text-white text-2xl font-bold mb-2"></h1>
<p className="text-white/70 text-base">{paymentInfo?.errorMessage || '支付处理过程中发生错误'}</p>
</div>
<div className="text-center text-white/70 mb-8 space-y-2">
<p>: {paymentInfo?.orderId || 'N/A'}</p>
<p></p>
</div>
<div className="space-y-3">
<Button
type="primary"
size="large"
onClick={handleRetryPayment}
data-alt="retry-payment-button"
className="w-full h-12 bg-white text-black hover:bg-white/90 border border-white/20"
>
</Button>
<Button
size="large"
onClick={handleGoBack}
data-alt="back-to-previous-page-button"
className="w-full h-12 bg-black text-white hover:bg-white hover:text-black border border-white/20"
>
</Button>
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Check, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
@ -8,8 +8,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { fetchSubscriptionPlans, SubscriptionPlan } from '@/lib/stripe';
export default function PricingPage() {
const router = useRouter();
const [billingCycle, setBillingCycle] = useState<'month' | 'year'>('month');
return (
<div className="w-full h-full overflow-y-auto bg-black text-white pb-[10rem]">
{/* Main Content */}
<HomeModule5 />
</div>
);
}
/**价格方案 */
function HomeModule5() {
const [billingType, setBillingType] = useState<'month' | 'year'>(
"month"
);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
// 从后端获取订阅计划数据
@ -26,25 +38,23 @@ export default function PricingPage() {
loadPlans();
}, []);
// 转换后端数据为前端显示格式,保持原有的数据结构
const transformPlanForDisplay = (plan: SubscriptionPlan) => {
const monthlyPrice = plan.price_month / 100; // 后端存储的是分,转换为元
const yearlyPrice = plan.price_year / 100;
return {
name: plan.name,
displayName: plan.display_name,
price: {
month: monthlyPrice,
year: yearlyPrice
},
description: plan.description,
features: plan.features || [],
popular: plan.is_popular, // 使用后端返回的 is_popular 字段
buttonText: plan.is_free ? 'Start Free Trial' : 'Subscribe',
buttonVariant: plan.is_free ? 'outline' as const : 'default' as const
};
};
const pricingPlans = useMemo<{
title: string;
price: number;
credits: string;
buttonText: string;
features: string[];
}[]>(() => {
return plans.map((plan) => {
return {
title: plan.display_name || plan.name,
price: billingType === "month" ? plan.price_month/100 : plan.price_year/100,
credits: plan.description,
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
features: plan.features || [],
};
});
}, [plans, billingType]);
const handleSubscribe = async (planName: string) => {
if (planName === 'hobby') {
@ -57,226 +67,145 @@ export default function PricingPage() {
// 从localStorage获取当前用户信息
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
throw new Error("无法获取用户ID请重新登录");
}
// 1. 创建Checkout Session
const result = await createCheckoutSession({
user_id: String(User.id),
plan_name: planName,
billing_cycle: billingCycle
billing_cycle: billingType
});
if (!result.successful || !result.data) {
throw new Error("create checkout session failed");
}
// 2. 直接跳转到Stripe托管页面就这么简单
redirectToCheckout(result.data.checkout_url);
} catch (error) {
throw new Error("create checkout session failed, please try again later");
}
};
// 如果还没有加载到数据,显示加载状态但保持原有样式
if (plans.length === 0) {
return (
<div className="min-h-screen bg-black text-white">
<main className="container mx-auto px-6 py-16">
<div className="mb-8">
<Button
variant="ghost"
onClick={() => router.back()}
className="text-gray-400 hover:text-white"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
<div className="text-center mb-16">
<h1 className="text-6xl font-bold mb-6">Pricing</h1>
<p className="text-xl text-gray-400 mb-8">Choose the plan that suits you best</p>
<div className="inline-flex items-center bg-gray-900/50 rounded-full p-1">
<button
onClick={() => setBillingCycle('year')}
className={`flex items-center justify-center px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
billingCycle === 'year'
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
: 'text-gray-300 hover:text-white'
}`}
>
Pay Yearly
<span className={`ml-2 text-sm ${
billingCycle === 'year'
? 'text-white/90'
: 'text-gray-400'
}`}>
Up to 20% off 🔥
</span>
</button>
<button
onClick={() => setBillingCycle('month')}
className={`px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
billingCycle === 'month'
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
: 'text-gray-300 hover:text-white'
}`}
>
Pay Monthly
</button>
</div>
</div>
<div className="mb-16">
<div className="text-center text-gray-400">
<p>...</p>
</div>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-black text-white">
{/* Main Content */}
<main className="container mx-auto px-6 py-16">
{/* Back Button */}
<div className="mb-8">
<Button
variant="ghost"
onClick={() => router.back()}
className="text-gray-400 hover:text-white"
<div
data-alt="core-value-section"
className="home-module5 h-[1600px] relative flex flex-col items-center justify-center w-full bg-black snap-start"
>
<div
data-alt="core-value-content"
className="center z-10 flex flex-col items-center mb-[8rem]"
>
<h2 className="text-white text-[3.375rem] leading-[100%] font-normal mb-[1.5rem]">
Start Creating
</h2>
{/* 计费切换 */}
<div className="h-[3.375rem] flex bg-black rounded-full p-[0.0625rem] mt-[1.5rem] border border-white/20">
<button
onClick={() => setBillingType("month")}
className={`box-border flex justify-center items-center w-[6rem] text-base rounded-full transition-all duration-300 ${
billingType === "month"
? "bg-white text-black"
: "text-white hover:text-gray-300"
}`}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
Monthly
</button>
<button
onClick={() => setBillingType("year")}
className={`box-border flex justify-center items-center w-[7.125rem] text-base rounded-full transition-all duration-300 ${
billingType === "year"
? "bg-white text-black"
: "text-white hover:text-gray-300"
}`}
>
Yearly - <span className="text-[#FFCC6D]">10%</span>
</button>
</div>
<div className="text-center mb-16">
<h1 className="text-6xl font-bold mb-6">Pricing</h1>
<p className="text-xl text-gray-400 mb-8">Choose the plan that suits you best</p>
{/* Billing Toggle */}
<div className="inline-flex items-center bg-gray-900/50 rounded-full p-1">
<button
onClick={() => setBillingCycle('year')}
className={`flex items-center justify-center px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
billingCycle === 'year'
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
: 'text-gray-300 hover:text-white'
}`}
>
Pay Yearly
<span className={`ml-2 text-sm ${
billingCycle === 'year'
? 'text-white/90'
: 'text-gray-400'
}`}>
Up to 20% off 🔥
</div>
{/* 主要价格卡片 */}
<div className="flex justify-between w-[90%] px-[12rem] mb-[2rem]">
{pricingPlans.map((plan, index) => (
<div
key={index}
className=" w-[26rem] h-[38.125rem] bg-black rounded-lg p-[1.5rem] border border-white/20"
>
<h3 className="text-white text-2xl font-normal mb-[1rem]">
{plan.title}
</h3>
<div className="mb-[1rem]">
<span className="text-white text-[3.375rem] font-bold">
${plan.price}
</span>
<span className="text-white text-xs ml-[0.5rem]">/month</span>
</div>
<p className="text-white text-[0.875rem] mb-[1rem]">
{plan.credits}
</p>
<button onClick={() => handleSubscribe(plan.title)} className="w-full bg-white text-black py-[0.75rem] rounded-full mb-[1rem] hover:bg-black hover:text-white transition-colors border border-white/20">
{plan.buttonText}
</button>
<button
onClick={() => setBillingCycle('month')}
className={`px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
billingCycle === 'month'
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
: 'text-gray-300 hover:text-white'
}`}
>
Pay Monthly
</button>
<p className="w-full text-center text-white/60 text-[0.75rem] mb-[2rem]">
* Billed monthly until cancelled
</p>
<ul className="space-y-[1rem]">
{plan.features.map((feature, featureIndex) => (
<li
key={featureIndex}
className="flex items-center text-white text-[0.875rem]"
>
<span className="text-[#C73BFF] mr-[0.5rem]"></span>
{feature}
</li>
))}
</ul>
</div>
))}
</div>
{/* 额外价格卡片 */}
<div className="flex gap-[1.5rem] w-[90%] px-[12rem]">
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
Free
</h3>
<div className="mb-[1rem]">
<span className="text-white text-[2.5rem] font-bold">$0</span>
</div>
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
10 Video mins and 1 AI credit per week, 1 Express avatar, 4 Exports
per week with invideo watermark.
</p>
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
No access to generative features.
</p>
<button className="w-[9rem] bg-[#262626] text-white py-[0.75rem] rounded-full hover:bg-white hover:text-black transition-colors border border-white/20">
Try For Free
</button>
</div>
{/* Plans Section */}
<div className="mb-16">
<div className="grid md:grid-cols-3 gap-8 max-w-7xl mx-auto">
{plans.map((plan) => {
const displayPlan = transformPlanForDisplay(plan);
return (
<div
key={plan.id}
className={`flex flex-col h-full transition-all duration-300 ${
displayPlan.popular ? 'hover:scale-105 hover:shadow-2xl' : ''
}`}
style={{ minHeight: '540px' }}
>
{/* 预留标签空间,确保所有卡片组合高度一致 */}
<div className="h-10 flex items-center justify-center">
{displayPlan.popular && (
<div className="bg-gradient-to-r from-pink-500 to-purple-600 text-white py-2 text-center text-sm font-medium rounded-t-2xl w-full shadow-lg">
Most Popular
</div>
)}
</div>
<Card
className={`bg-gray-900/50 ${displayPlan.popular ? 'border-l border-r border-b border-gray-700/50 rounded-b-2xl rounded-t-none' : 'border border-gray-700/50 rounded-2xl'} overflow-hidden ${displayPlan.popular ? '' : 'transition-all duration-300 hover:scale-105 hover:shadow-2xl'} flex flex-col flex-grow ${
displayPlan.popular ? 'ring-2 ring-pink-500/50 shadow-pink-500/20' : ''
}`}
>
<CardHeader className="pt-8 pb-6 flex-shrink-0 text-center">
<CardTitle className="text-white text-2xl font-bold mb-4">{displayPlan.displayName}</CardTitle>
<div className="mb-4">
<div className="text-4xl font-bold text-white">
{displayPlan.price[billingCycle] === 0 ? (
'Free'
) : (
<>
<span className="text-5xl">${displayPlan.price[billingCycle]}</span>
<span className="text-lg text-gray-400 font-normal">
/{billingCycle === 'month' ? 'month' : 'year'}
</span>
</>
)}
</div>
</div>
<CardDescription className="text-gray-300 text-base px-4">
{displayPlan.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-8 pb-10 flex-grow flex flex-col justify-between">
<div className="space-y-5">
{displayPlan.features.map((feature, index) => (
<div key={index} className="flex items-start space-x-3">
<div className="w-5 h-5 bg-green-500/20 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<Check className="w-3 h-3 text-green-400" />
</div>
<span className="text-gray-300 text-sm leading-relaxed">{feature}</span>
</div>
))}
</div>
<Button
onClick={() => handleSubscribe(displayPlan.name)}
variant={displayPlan.buttonVariant}
className={`w-full py-4 rounded-xl font-medium text-base transition-all duration-300 mt-6 ${
displayPlan.buttonText === 'Start Free Trial'
? 'border-2 border-gray-600 text-white hover:border-pink-500 hover:text-pink-400 hover:bg-gray-800/50'
: 'bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700 shadow-lg hover:shadow-xl'
}`}
>
{displayPlan.buttonText}
</Button>
</CardContent>
</Card>
</div>
);
})}
</div>
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
Enterprise
</h3>
<p className="text-white text-[2.5rem] mb-[1rem]">Custom</p>
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
Custom solutions for large organizations. Advanced security and
flexible pricing based on your needs.
</p>
<p className="text-white text-[0.875rem] mb-[1.5rem] leading-relaxed">
on your needs.
</p>
<button className="w-[9rem] bg-[#262626] text-white py-[0.75rem] rounded-full hover:bg-white hover:text-black transition-colors border border-white/20">
Contact Us
</button>
</div>
</main>
</div>
</div>
);
}

View File

@ -173,8 +173,8 @@ const RenderTemplateStoryMode = ({
// 模板列表渲染
const templateListRender = () => {
return (
<div className="w-1/3 p-4 border-r border-white/[0.1]">
<div className="space-y-4 max-h-[700px] overflow-y-auto template-list-scroll">
<div className="w-1/3 p-4 border-r border-white/[0.1] overflow-y-auto">
<div className="space-y-4 overflow-y-auto template-list-scroll">
{templateStoryList.map((template, index) => (
<div
key={template.id}
@ -562,7 +562,7 @@ const RenderTemplateStoryMode = ({
className="photo-story-modal !pb-0 rounded-lg bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
>
<GlobalLoad show={isLoading} progress={localLoading}>
<div className="rounded-2xl min-h-min transition-all duration-700 ease-out">
<div className="rounded-2xl h-[70vh] overflow-y-hidden flex flex-col">
{/* 弹窗头部 */}
<div className="flex items-center justify-between px-4 pb-6 border-b border-white/[0.1]">
<h2 className="text-2xl font-bold text-white">
@ -576,7 +576,7 @@ const RenderTemplateStoryMode = ({
</button>
</div>
<div className="flex gap-4 pb-8 ">
<div className="flex gap-4 pb-8 flex-1 overflow-y-hidden">
{templateListRender()}
<div className="flex-1">{storyEditorRender()}</div>
</div>
@ -591,7 +591,7 @@ const RenderTemplateStoryMode = ({
*
* /
*/
export function ChatInputBox() {
export function ChatInputBox({ noData }: { noData: boolean }) {
// 控制面板展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
@ -670,28 +670,36 @@ export function ChatInputBox() {
};
return (
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]">
<div className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]" style={noData ? {
top: '50%'
} : {}}>
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
<div className="video-storyboard-tools rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
{/* 展开/收起控制区域 */}
{isExpanded ? (
// 展开状态:显示收起按钮和提示
<div
className="absolute top-0 bottom-0 left-0 right-0 z-[1] flex flex-col items-center justify-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer"
onClick={() => setIsExpanded(false)}
>
<ChevronUp className="w-4 h-4 text-white/80" />
<span className="text-sm text-white/80 mt-1">Click to action</span>
</div>
) : (
// 收起状态:显示展开按钮
<div
className="absolute top-[-8px] left-[50%] z-[2] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]"
onClick={() => setIsExpanded(true)}
>
<ChevronDown className="w-4 h-4" />
</div>
)}
{
!noData && (
<>
{isExpanded ? (
// 展开状态:显示收起按钮和提示
<div
className="absolute top-0 bottom-0 left-0 right-0 z-[1] flex flex-col items-center justify-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer"
onClick={() => setIsExpanded(false)}
>
<ChevronUp className="w-4 h-4 text-white/80" />
<span className="text-sm text-white/80 mt-1">Click to action</span>
</div>
) : (
// 收起状态:显示展开按钮
<div
className="absolute top-[-8px] left-[50%] z-[2] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]"
onClick={() => setIsExpanded(true)}
>
<ChevronDown className="w-4 h-4" />
</div>
)}
</>
)
}
{/* 主要内容区域 - 简化层级,垂直居中 */}
<div
@ -744,6 +752,9 @@ export function ChatInputBox() {
onChange={(e) => setScript(e.target.value)}
placeholder="Describe the content you want to action..."
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'
} : {}}
rows={1}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;

View File

@ -0,0 +1,183 @@
import { notification } from 'antd';
const darkGlassStyle = {
background: 'rgba(30, 32, 40, 0.95)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
padding: '12px 16px',
};
/** 胶片容器样式 */
const filmStripContainerStyle = {
position: 'relative' as const,
width: '100%',
height: '80px',
marginBottom: '16px',
overflow: 'hidden',
};
/** 胶片样式 */
const filmStripStyle = {
display: 'flex',
alignItems: 'center',
animation: 'filmScroll 20s linear infinite',
};
/** 文字样式 */
const textStyle = {
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '12px',
};
/** 胶片帧组件 */
const FilmFrame = () => (
<div style={{ margin: '0 4px' }}>
<svg width="60" height="80" viewBox="0 0 60 80" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* 胶片外框 */}
<rect x="0" y="0" width="60" height="80" fill="#1A1B1E" stroke="#F6B266" strokeWidth="1"/>
{/* 齿孔 */}
<circle cx="10" cy="5" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
<circle cx="50" cy="5" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
<circle cx="10" cy="75" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
<circle cx="50" cy="75" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
{/* 胶片画面区域 */}
<rect x="5" y="15" width="50" height="50" fill="#2A2B2E" stroke="#F6B266" strokeWidth="1"/>
</svg>
</div>
);
/** 放映机音效组件 */
const ProjectorSound = () => (
<audio
src="/assets/audio/projector.mp3"
autoPlay
loop
style={{ display: 'none' }}
/>
);
/**
*
* @param position -
* @param estimatedMinutes -
*/
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
notification.open({
message: null,
description: (
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
{/* 胶片动画区域 */}
<div style={filmStripContainerStyle}>
<div style={filmStripStyle}>
{[...Array(6)].map((_, i) => (
<FilmFrame key={i} />
))}
</div>
<div style={filmStripStyle} className="film-strip-2">
{[...Array(6)].map((_, i) => (
<FilmFrame key={i} />
))}
</div>
</div>
{/* 队列信息 */}
<div style={textStyle}>
<span style={{ marginRight: '8px' }}>🎬</span>
{position}
</div>
{/* 预计等待时间 */}
<div style={{ ...textStyle, color: 'rgba(255, 255, 255, 0.65)' }}>
{estimatedMinutes}
</div>
{/* 取消按钮 */}
<button
onClick={() => notification.destroy()}
style={{
color: 'rgb(250 173 20 / 90%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: '12px',
fontWeight: 500,
textDecoration: 'underline',
textUnderlineOffset: '2px',
textDecorationColor: 'rgb(250 173 20 / 60%)',
transition: 'all 0.2s ease',
}}
data-alt="cancel-queue-button"
>
</button>
{/* 放映机音效 */}
<ProjectorSound />
</div>
),
duration: 0,
placement: 'topRight',
style: {
...darkGlassStyle,
border: '1px solid rgba(246, 178, 102, 0.2)',
},
className: 'movie-queue-notification',
closeIcon: (
<button
className="hover:text-white"
style={{
background: 'transparent',
border: 'none',
padding: '2px',
cursor: 'pointer',
color: 'rgba(255, 255, 255, 0.45)',
transition: 'color 0.2s ease',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
),
});
};
// 添加必要的CSS动画
const styles = `
@keyframes filmScroll {
0% { transform: translateX(0); }
100% { transform: translateX(-360px); }
}
.film-strip-2 {
position: absolute;
top: 0;
left: 360px;
}
.movie-queue-notification {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
// 将样式注入到页面
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
// 配置通知
notification.config({
maxCount: 3,
});

View File

@ -0,0 +1,238 @@
import { notification } from 'antd';
const darkGlassStyle = {
background: 'rgba(30, 32, 40, 0.95)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
padding: '12px 16px',
};
/** AI导演工作室容器样式 */
const studioContainerStyle = {
position: 'relative' as const,
width: '100%',
height: '120px',
marginBottom: '16px',
background: 'rgba(26, 27, 30, 0.6)',
borderRadius: '8px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
/** AI导演组件 */
const AIDirector = () => (
<div className="ai-director">
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* AI导演的圆形头部 */}
<circle cx="50" cy="40" r="25" fill="#F6B266"/>
{/* 眼睛 */}
<circle cx="40" cy="35" r="5" fill="#2A2B2E"/>
<circle cx="60" cy="35" r="5" fill="#2A2B2E"/>
{/* 笑容 */}
<path d="M40 45 Q50 55 60 45" stroke="#2A2B2E" strokeWidth="3" strokeLinecap="round"/>
{/* 导演帽 */}
<path d="M25 30 H75 V25 H25" fill="#2A2B2E"/>
{/* 身体 */}
<rect x="35" y="65" width="30" height="25" fill="#F6B266"/>
{/* 手臂 - 动画中会移动 */}
<rect className="director-arm" x="25" y="70" width="15" height="5" fill="#F6B266"/>
<rect className="director-arm" x="60" y="70" width="15" height="5" fill="#F6B266"/>
</svg>
</div>
);
/** 工作进度条组件 */
const ProgressTimeline = () => (
<div className="progress-timeline" style={{
position: 'absolute',
bottom: '10px',
left: '20px',
right: '20px',
height: '4px',
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: '2px',
}}>
<div className="progress-indicator" style={{
width: '30%',
height: '100%',
background: '#F6B266',
borderRadius: '2px',
animation: 'progress 2s ease-in-out infinite',
}}/>
</div>
);
/** 工作台元素组件 */
const Workstation = () => (
<div className="workstation" style={{
position: 'absolute',
bottom: '20px',
width: '100%',
display: 'flex',
justifyContent: 'space-around',
}}>
{/* 小型场景图标,会在动画中浮动 */}
{[...Array(3)].map((_, i) => (
<div key={i} className={`scene-icon scene-${i}`} style={{
width: '20px',
height: '20px',
background: 'rgba(246, 178, 102, 0.3)',
borderRadius: '4px',
animation: `float ${1 + i * 0.5}s ease-in-out infinite alternate`,
}}/>
))}
</div>
);
/**
*
* @param position -
* @param estimatedMinutes -
*/
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
notification.open({
message: null,
description: (
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
{/* AI导演工作室场景 */}
<div style={studioContainerStyle}>
<AIDirector />
<Workstation />
<ProgressTimeline />
</div>
{/* 队列信息 */}
<div style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
background: 'rgba(246, 178, 102, 0.1)',
padding: '8px 12px',
borderRadius: '6px',
}}>
<span style={{ marginRight: '8px' }}>🎬</span>
{position}
</div>
{/* 预计等待时间 */}
<div style={{
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.65)',
marginBottom: '12px',
}}>
{estimatedMinutes}
</div>
{/* 取消按钮 */}
<button
onClick={() => notification.destroy()}
style={{
color: 'rgb(250 173 20 / 90%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: '12px',
fontWeight: 500,
textDecoration: 'underline',
textUnderlineOffset: '2px',
textDecorationColor: 'rgb(250 173 20 / 60%)',
transition: 'all 0.2s ease',
}}
data-alt="cancel-queue-button"
>
</button>
</div>
),
duration: 0,
placement: 'topRight',
style: {
...darkGlassStyle,
border: '1px solid rgba(246, 178, 102, 0.2)',
},
className: 'director-studio-notification',
closeIcon: (
<button
className="hover:text-white"
style={{
background: 'transparent',
border: 'none',
padding: '2px',
cursor: 'pointer',
color: 'rgba(255, 255, 255, 0.45)',
transition: 'color 0.2s ease',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
),
});
};
// 添加必要的CSS动画
const styles = `
.ai-director {
animation: bounce 2s ease-in-out infinite;
}
.director-arm {
transform-origin: center;
animation: wave 1s ease-in-out infinite alternate;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes wave {
0% { transform: rotate(-5deg); }
100% { transform: rotate(5deg); }
}
@keyframes float {
0% { transform: translateY(0); }
100% { transform: translateY(-10px); }
}
@keyframes progress {
0% { width: 0%; }
50% { width: 60%; }
100% { width: 30%; }
}
.director-studio-notification {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.scene-0 { animation-delay: 0s; }
.scene-1 { animation-delay: 0.2s; }
.scene-2 { animation-delay: 0.4s; }
`;
// 将样式注入到页面
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
// 配置通知
notification.config({
maxCount: 3,
});

View File

@ -0,0 +1,209 @@
import { notification } from 'antd';
import { useRouter } from 'next/router';
type NotificationType = 'success' | 'info' | 'warning' | 'error';
const darkGlassStyle = {
background: 'rgba(30, 32, 40, 0.95)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
padding: '12px 16px',
};
const messageStyle = {
fontSize: '13px',
fontWeight: 500,
color: '#ffffff',
marginBottom: '6px',
display: 'flex',
alignItems: 'center',
gap: '6px',
};
const iconStyle = {
color: '#F6B266', // 警告图标颜色
background: 'rgba(246, 178, 102, 0.15)',
padding: '4px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const descriptionStyle = {
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.65)',
marginBottom: '12px',
lineHeight: '1.5',
};
const btnStyle = {
color: 'rgb(250 173 20 / 90%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: '12px',
fontWeight: 500,
textDecoration: 'underline',
textUnderlineOffset: '2px',
textDecorationColor: 'rgb(250 173 20 / 60%)',
transition: 'all 0.2s ease',
};
/** 场记板动画样式 */
const clapperboardStyle = {
position: 'relative' as const,
width: '40px',
height: '40px',
marginRight: '12px',
animation: 'clap 2s infinite',
};
/** 场记板文字样式 */
const sceneTextStyle = {
fontSize: '14px',
fontFamily: 'monospace',
color: '#F6B266',
marginBottom: '8px',
letterSpacing: '0.5px',
};
/** 队列信息样式 */
const queueInfoStyle = {
display: 'flex',
alignItems: 'center',
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '12px',
background: 'rgba(246, 178, 102, 0.1)',
padding: '8px 12px',
borderRadius: '6px',
};
/**
* SVG组件
*/
const Clapperboard = () => (
<div style={clapperboardStyle}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4H20V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V4Z"
stroke="#F6B266" strokeWidth="1.5"/>
<path d="M4 8H20" stroke="#F6B266" strokeWidth="1.5"/>
<path d="M9 4L11 8M15 4L17 8" stroke="#F6B266" strokeWidth="1.5"/>
<path className="clap-top" d="M4 4L20 4L17 8L4 8L4 4Z"
fill="rgba(246, 178, 102, 0.2)" stroke="#F6B266" strokeWidth="1.5"/>
</svg>
</div>
);
/**
*
* @param position -
* @param estimatedMinutes -
*/
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
// 生成场景号和镜次号
const sceneNumber = Math.floor(Math.random() * 5) + 1;
const takeNumber = Math.floor(Math.random() * 3) + 1;
notification.open({
message: null,
description: (
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
{/* 场记板和场景信息 */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
<Clapperboard />
<div>
<div style={sceneTextStyle}>
Scene {sceneNumber} - Take {takeNumber}
</div>
<div style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.6)' }}>
AI Director's Cut
</div>
</div>
</div>
{/* 队列信息 */}
<div style={queueInfoStyle}>
<span style={{ marginRight: '8px' }}>🎬</span>
{position}
</div>
{/* 预计等待时间 */}
<div style={descriptionStyle}>
{estimatedMinutes}
</div>
{/* 取消按钮 */}
<button
onClick={() => notification.destroy()}
style={{
...btnStyle,
marginTop: '8px',
}}
data-alt="cancel-queue-button"
>
</button>
</div>
),
duration: 0, // 保持通知直到用户关闭
placement: 'topRight',
style: {
...darkGlassStyle,
border: '1px solid rgba(246, 178, 102, 0.2)',
},
className: 'movie-queue-notification',
closeIcon: (
<button
className="hover:text-white"
style={{
background: 'transparent',
border: 'none',
padding: '2px',
cursor: 'pointer',
color: 'rgba(255, 255, 255, 0.45)',
transition: 'color 0.2s ease',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
),
});
};
// 添加必要的CSS动画
const styles = `
@keyframes clap {
0%, 100% { transform: rotate(0deg); }
5% { transform: rotate(-15deg); }
10% { transform: rotate(0deg); }
}
.movie-queue-notification {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
// 将样式注入到页面
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
// 保持现有的notification配置
notification.config({
maxCount: 3,
});

View File

@ -12,8 +12,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
return (
<div className=" min-h-screen bg-background" id="app">
<Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />
<div className=" min-h-screen bg-background">
<TopBar collapsed={sidebarCollapsed} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} />
{children}
</div>

View File

@ -1,9 +1,9 @@
"use client";
import '../pages/style/top-bar.css';
import { Button } from '@/components/ui/button';
import { GradientText } from '@/components/ui/gradient-text';
import { useTheme } from 'next-themes';
import "../pages/style/top-bar.css";
import { Button } from "@/components/ui/button";
import { GradientText } from "@/components/ui/gradient-text";
import { useTheme } from "next-themes";
import {
Sun,
Moon,
@ -11,14 +11,14 @@ import {
Sparkles,
LogOut,
PanelsLeftBottom,
Bell,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import ReactDOM from 'react-dom';
import { useRouter } from 'next/navigation';
import { useRouter, usePathname } from 'next/navigation';
import React, { useRef, useEffect, useLayoutEffect, useState } from 'react';
import { logoutUser } from '@/lib/auth';
import { createPortalSession, redirectToPortal } from '@/lib/stripe';
import { showQueueNotification } from '@/components/QueueBox/QueueNotification2';
interface User {
id: string;
@ -27,15 +27,23 @@ interface User {
avatar: string;
}
export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onToggleSidebar: () => void }) {
export function TopBar({
collapsed,
onToggleSidebar,
}: {
collapsed: boolean;
onToggleSidebar: () => void;
}) {
const router = useRouter();
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const currentUser: User = JSON.parse(localStorage.getItem('currentUser') || '{}');
const currentUser: User = JSON.parse(
localStorage.getItem("currentUser") || "{}"
);
const [mounted, setMounted] = React.useState(false);
const [isLogin, setIsLogin] = useState(false);
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
const pathname = usePathname()
useEffect(() => {
const currentUser = localStorage.getItem("currentUser");
if (JSON.parse(currentUser || "{}")?.token) {
@ -45,40 +53,11 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
}
});
useLayoutEffect(() => {
console.log('Setting mounted state');
console.log("Setting mounted state");
setMounted(true);
return () => console.log('Cleanup mounted effect');
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.error('创建订阅管理会话失败:', response.message);
alert('无法打开订阅管理页面,请稍后重试');
}
} catch (error) {
console.error('打开订阅管理页面失败:', error);
alert('无法打开订阅管理页面,请稍后重试');
} finally {
setIsManagingSubscription(false);
}
};
// 处理点击事件
useEffect(() => {
if (!isOpen) return;
@ -88,16 +67,14 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
isClickStartedInside = !!(
menuRef.current?.contains(target) ||
buttonRef.current?.contains(target)
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)
menuRef.current?.contains(target) || buttonRef.current?.contains(target)
);
// 只有当点击开始和结束都在外部时才关闭
@ -108,12 +85,12 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
};
// 在冒泡阶段处理事件
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isOpen]);
@ -128,21 +105,14 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
};
return (
<div className="fixed right-0 top-0 left-0 h-16 header z-[999]" style={{ isolation: 'isolate' }}>
<div
className="fixed right-0 top-0 left-0 h-16 header z-[999]"
style={{ isolation: "isolate" }}
>
<div className="h-full flex items-center justify-between pr-6 pl-6">
<div className="flex items-center space-x-4">
{isLogin && (
<Button
className="button-NxtqWZ"
variant="ghost"
size="sm"
onClick={onToggleSidebar}
>
<PanelsLeftBottom className="h-4 w-4" />
</Button>
)}
<div
className={`flex items-center cursor-pointer space-x-4 link-logo roll event-on`}
className={`flex items-center cursor-pointer space-x-1 link-logo roll event-on`}
onClick={() => router.push("/")}
onMouseEnter={handleMouseEnter}
onAnimationEnd={handleAnimationEnd}
@ -181,19 +151,22 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
<Button
variant="ghost"
size="sm"
onClick={() => router.push('/pricing')}
onClick={() => {
localStorage.setItem("callBackUrl", pathname);
router.push("/pricing");
}}
className="text-gray-300 hover:text-white"
>
Pricing
</Button>
{/* Notifications */}
{/* <Button variant="ghost" size="sm" onClick={() => setOpenModal(true)}>
{/* <Button variant="ghost" size="sm" onClick={() => showQueueNotification(3, 10)}>
<Bell className="h-4 w-4" />
</Button> */}
{/* Theme Toggle */}
{/* <Button
{/* Theme Toggle */}
{/* <Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
@ -203,13 +176,13 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
</Button> */}
{/* User Menu */}
<div className="relative" style={{ isolation: 'isolate' }}>
<div className="relative" style={{ isolation: "isolate" }}>
<Button
ref={buttonRef}
variant="ghost"
size="sm"
onClick={() => {
console.log('Button clicked, current isOpen:', isOpen);
console.log("Button clicked, current isOpen:", isOpen);
setIsOpen(!isOpen);
}}
data-alt="user-menu-trigger"
@ -217,79 +190,80 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
<User className="h-4 w-4" />
</Button>
{mounted && (
ReactDOM.createPortal(
(<AnimatePresence>
{isOpen && (
<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',
width: '18rem',
zIndex: 9999
}}
className="bg-[#1E1E1E] rounded-lg shadow-lg overflow-hidden"
data-alt="user-menu-dropdown"
onClick={(e) => e.stopPropagation()}
>
{/* User Info */}
<div className="p-4">
<div className="flex items-center space-x-3">
<div className="h-10 w-10 rounded-full bg-[#1E4D3E] flex items-center justify-center text-white font-semibold">
{currentUser.name ? currentUser.name.charAt(0) : ''}
</div>
<div className='flex-1'>
<p className="text-sm font-medium">{currentUser.name}</p>
<p className="text-xs text-gray-500">{currentUser.email}</p>
</div>
<div
className='cursor-pointer hover:text-red-400 transition-colors duration-200'
onClick={() => {
logoutUser();
}}
title="退出登录"
>
<LogOut className="h-4 w-4" />
{mounted && isOpen
? ReactDOM.createPortal(
<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",
width: "18rem",
zIndex: 9999,
}}
className="bg-[#1E1E1E] rounded-lg shadow-lg overflow-hidden"
data-alt="user-menu-dropdown"
onClick={(e) => e.stopPropagation()}
>
{/* User Info */}
<div className="p-4">
<div className="flex items-center space-x-3">
<div className="h-10 w-10 rounded-full bg-[#1E4D3E] flex items-center justify-center text-white font-semibold">
{currentUser.name ? currentUser.name.charAt(0) : ""}
</div>
<div className="flex-1">
<p className="text-sm font-medium">
{currentUser.name}
</p>
<p className="text-xs text-gray-500">
{currentUser.email}
</p>
</div>
<div
className="cursor-pointer hover:text-red-400 transition-colors duration-200"
onClick={() => {
logoutUser();
}}
title="退出登录"
>
<LogOut className="h-4 w-4" />
</div>
</div>
</div>
</div>
{/* AI Points */}
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Sparkles className="h-4 w-4" />
<span className="text-white underline text-sm">100 credits</span>
</div>
<div className="flex flex-col gap-2">
{/* AI Points */}
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Sparkles className="h-4 w-4" />
<span className="text-white underline text-sm">
100 credits
</span>
</div>
<Button
variant="outline"
size="sm"
className="text-white border-white hover:bg-white/10 rounded-full px-8"
onClick={() => router.push('/pricing')}
className="text-white border-white hover:bg-white/10 rounded-full px-3 py-1 text-[10px]"
onClick={() => router.push("/pricing")}
>
Upgrade
</Button>
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-white hover:bg-white/10 rounded-full px-4 text-xs"
onClick={handleManageSubscription}
disabled={isManagingSubscription}
className="text-white border-white hover:bg-white/10 rounded-full px-3 py-1 text-[10px]"
onClick={() => router.push("/pricing")}
>
{isManagingSubscription ? 'Loading...' : 'Manage Subscription'}
Manage
</Button>
</div>
</div>
{/* Menu Items */}
<div className="p-2">
{/* <motion.button
{/* Menu Items */}
<div className="p-2">
{/* <motion.button
whileHover={{ backgroundColor: 'rgba(255,255,255,0.1)' }}
className="w-full flex items-center space-x-2 px-3 py-2 rounded-md text-sm text-white"
onClick={() => router.push('/my-library')}
@ -312,22 +286,19 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
<span>Logout</span>
</motion.button> */}
{/* Footer */}
<div className="mt-4 px-3 py-2 text-xs text-gray-400 text-center">
<div>Privacy Policy · Terms of Service</div>
<div>250819215404 | 2025/8/20 06:00:50</div>
{/* Footer */}
<div className="mt-4 px-3 py-2 text-xs text-gray-400 text-center">
<div>Privacy Policy · Terms of Service</div>
<div>250819215404 | 2025/8/20 06:00:50</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>) as React.ReactElement,
document.body
) as React.ReactNode
)}
</motion.div>,
document.body
)
: null}
</div>
</div>
</div>
</div>
);
}

View File

@ -8,6 +8,8 @@ import './style/create-to-video2.css';
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
import cover_image1 from '@/public/assets/cover_image1.jpg';
import { motion } from 'framer-motion';
// ideaText已迁移到ChatInputBox组件中
@ -98,6 +100,139 @@ export default function CreateToVideo2() {
setIsClient(true);
}, []);
const StatusBadge = (status: string) => {
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="flex items-center gap-2 rounded-full
bg-white/10 border border-white/20
px-3 py-1 backdrop-blur-md shadow-[0_0_8px_rgba(255,255,255,0.3)]"
>
{/* 进行中 脉冲小圆点 */}
{status === 'pending' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-yellow-400 shadow-[0_0_8px_rgba(255,220,100,0.9)]"
animate={{ scale: [1, 1.4, 1] }}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
{/* 状态文字 */}
<span className="text-xs tracking-widest text-yellow-300 font-medium drop-shadow-[0_0_6px_rgba(255,220,100,0.6)]">
PROCESSING
</span>
</>
)}
{/* 已完成 */}
{status === 'completed' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-green-400 shadow-[0_0_8px_rgba(0,255,120,0.9)]"
/>
<span className="text-xs tracking-widest text-green-300 font-medium drop-shadow-[0_0_6px_rgba(0,255,120,0.6)]">
COMPLETED
</span>
</>
)}
{/* 失败 */}
{status === 'failed' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_8px_rgba(255,0,80,0.9)]"
/>
<span className="text-xs tracking-widest text-red-400 font-medium drop-shadow-[0_0_6px_rgba(255,0,80,0.6)]">
FAILED
</span>
</>
)}
</motion.div>
)
}
// 创建一个视频引用Map
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const handleMouseEnter = (projectId: string) => {
const videoElement = videoRefs.current.get(projectId);
if (videoElement) {
videoElement.play().catch(() => {
console.log('Video autoplay prevented');
});
}
};
const handleMouseLeave = (projectId: string) => {
const videoElement = videoRefs.current.get(projectId);
if (videoElement) {
videoElement.pause();
videoElement.currentTime = 0;
}
};
const setVideoRef = (projectId: string, element: HTMLVideoElement | null) => {
if (element) {
videoRefs.current.set(projectId, element);
} else {
videoRefs.current.delete(projectId);
}
};
const renderProjectCard = (project: any) => {
return (
<div
key={project.project_id}
className="group relative aspect-video bg-black/20 rounded-lg overflow-hidden cursor-pointer"
onClick={() => router.push(`/create/work-flow?episodeId=${project.project_id}`)}
onMouseEnter={() => handleMouseEnter(project.project_id)}
onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card"
>
{/* 视频/图片区域 */}
{project.final_video_url ? (
<video
ref={(el) => setVideoRef(project.project_id, el)}
src={project.final_video_url}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
muted
loop
playsInline
preload="none"
poster={`${project.final_video_url}?vframe/jpg/offset/1`}
/>
) : (
<div
className="w-full h-full bg-cover bg-center group-hover:scale-105 transition-transform duration-500"
style={{
backgroundImage: `url(${cover_image1.src})`,
}}
/>
)}
{/* 渐变遮罩 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* 状态标签 - 左上角 */}
<div className="absolute top-3 left-3">
{StatusBadge(project.status === 'COMPLETED' ? 'completed' : project.status === 'FAILED' ? 'failed' : 'pending')}
</div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-white group-hover:text-purple-400 transition-colors duration-300 line-clamp-1">
{project.name || "未命名项目"}
</h2>
<button className="p-1.5 hover:bg-white/10 rounded-full transition-colors opacity-0 group-hover:opacity-100">
<MoreHorizontal className="w-4 h-4 text-white/80" />
</button>
</div>
</div>
</div>
);
};
// 渲染剧集卡片
const renderEpisodeCard = (episode: any) => {
return (
@ -190,12 +325,12 @@ export default function CreateToVideo2() {
return (
<>
<div className="container mx-auto h-[calc(100vh-6rem)] flex flex-col absolute top-[4rem] left-0 right-0 px-[1rem]">
<div className="flex flex-col absolute top-[5rem] left-0 right-0 bottom-[1rem] px-6">
{/* 优化后的主要内容区域 */}
<div className="flex-1 min-h-0">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto custom-scrollbar"
className="h-full overflow-y-auto overflow-x-hidden custom-scrollbar"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(255,255,255,0.1) transparent'
@ -203,39 +338,51 @@ export default function CreateToVideo2() {
>
{isLoading && episodeList.length === 0 ? (
/* 优化的加载状态 */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6 pb-6">
{[...Array(10)].map((_, index) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 pb-6">
{[...Array(6)].map((_, index) => (
<div
key={index}
className="bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-2xl overflow-hidden animate-pulse"
className="group relative aspect-video bg-black/20 rounded-lg overflow-hidden animate-pulse"
>
<div className="h-[200px] bg-gradient-to-br from-white/[0.08] to-white/[0.04]">
<div className="h-full bg-white/[0.06] animate-pulse"></div>
</div>
<div className="p-5">
<div className="h-4 bg-white/[0.08] rounded-lg mb-3 animate-pulse"></div>
<div className="h-3 bg-white/[0.06] rounded-lg mb-4 w-3/4 animate-pulse"></div>
<div className="flex justify-between">
<div className="h-3 bg-white/[0.06] rounded-lg w-20 animate-pulse"></div>
<div className="h-3 bg-white/[0.06] rounded-lg w-16 animate-pulse"></div>
{/* 背景占位 */}
<div className="w-full h-full bg-gradient-to-br from-white/[0.04] to-white/[0.02]" />
{/* 渐变遮罩 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* 状态标签占位 */}
<div className="absolute top-3 left-3">
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1">
<div className="w-2 h-2 rounded-full bg-white/20"></div>
<div className="w-16 h-3 bg-white/20 rounded-full"></div>
</div>
</div>
{/* 项目ID占位 */}
<div className="absolute top-3 right-3">
<div className="w-20 h-3 bg-white/10 rounded-full"></div>
</div>
{/* 底部信息占位 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<div className="w-2/3 h-5 bg-white/10 rounded-lg"></div>
</div>
</div>
))}
</div>
) : episodeList.length > 0 ? (
/* 优化的剧集网格 */
<div className="pb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{episodeList.map(renderEpisodeCard)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{episodeList.map(renderProjectCard)}
</div>
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex justify-center py-12">
<div className="flex items-center gap-3 px-6 py-3 bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-xl">
<Loader2 className="w-5 h-5 animate-spin text-blue-400" />
<span className="text-white/70 font-medium">Loading more episodes...</span>
<div className="flex items-center gap-3 px-6 py-3 bg-black/30 backdrop-blur-xl border border-white/10 rounded-full">
<Loader2 className="w-5 h-5 animate-spin text-purple-400" />
<span className="text-white/90 font-medium">...</span>
</div>
</div>
)}
@ -244,10 +391,10 @@ export default function CreateToVideo2() {
{!hasMore && episodeList.length > 0 && (
<div className="flex justify-center py-12">
<div className="text-center">
<div className="w-12 h-12 bg-white/[0.05] backdrop-blur-[20px] border border-white/[0.08] rounded-xl flex items-center justify-center mx-auto mb-3">
<Check className="w-6 h-6 text-green-400" />
<div className="w-12 h-12 bg-black/30 backdrop-blur-xl border border-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Check className="w-6 h-6 text-purple-400" />
</div>
<p className="text-white/50 text-sm">All episodes loaded</p>
<p className="text-white/70 text-sm"></p>
</div>
</div>
)}
@ -260,13 +407,9 @@ export default function CreateToVideo2() {
</div>
{/* 视频工具组件 - 使用独立组件 */}
<ChatInputBox />
{episodeList.length === 0 && !isLoading && (
<div className='h-full flex flex-col items-center fixed top-[4rem] left-0 right-0 bottom-0'>
<EmptyStateAnimation className='' />
</div>
)}
{!isLoading &&
<ChatInputBox noData={episodeList.length === 0} />
}
</>
);
}

View File

@ -9,7 +9,7 @@ import {
CircleArrowRight,
} from "lucide-react";
import "./style/home-page2.css";
import { useRouter } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/swiper-bundle.css"; // 引入样式
import { Autoplay } from "swiper/modules";
@ -26,17 +26,47 @@ import { ProjectTypeEnum, ModeEnum, ResolutionEnum } from "@/app/model/enums";
import { getResourcesList, Resource } from "@/api/resources";
import { Carousel } from "antd";
import { TextCanvas } from "../common/TextCanvas";
import { fetchSubscriptionPlans, SubscriptionPlan } from "@/lib/stripe";
export function HomePage2() {
const [hPading, setHPading] = useState(0);
useEffect(() => {
setHPading((window as any).Scale.hScale * 11);
// 获取当前窗口尺寸
const currentWidth = window.innerWidth;
const currentHeight = window.innerHeight;
// 计算缩放比例 (1920x1080)
const wScale = currentWidth / 1920;
const hScale = currentHeight / 1080;
// 检查app节点是否存在
const homePage = document.getElementById("home-page");
if (!homePage) {
console.error("未找到app节点");
return;
}
// setHPading((hScale || 1) * 10);
// 创建样式元素
const style = document.createElement("style");
// 设置CSS样式
style.textContent = `
#home-page {
transform-origin: top left;
transform: scale(${wScale}, ${hScale});
width: 1920px;
height: 1080px;
}
`;
// 将样式添加到head
document.head.appendChild(style);
}, []);
return (
//
<div
className="w-full h-full overflow-y-auto"
style={{ paddingBottom: `${hPading}rem` }}
className="w-full h-screen overflow-y-auto"
id="home-page"
style={{ paddingBottom: `2rem` }}
>
<HomeModule1 />
<HomeModule2 />
@ -52,7 +82,7 @@ export function HomePage2() {
function HomeModule1() {
const router = useRouter();
return (
<div className="home-module1 relative flex justify-center items-center w-full h-[1280px] bg-black snap-start">
<div className="home-module1 relative flex justify-center items-start pt-[28rem] w-full h-[1280px] bg-black snap-start">
<video
src="/assets/home.mp4"
autoPlay
@ -62,7 +92,7 @@ function HomeModule1() {
className="absolute top-0 left-0 z-1 w-full h-full object-cover"
></video>
<div className="center z-10 flex flex-col items-center">
<h1 className="text-white text-[5.75rem] leading-[100%] font-bold mb-[5rem]">
<h1 className="text-white text-[5.75rem] leading-[100%] font-bold mb-[1rem]">
Ideas Become Movies
</h1>
<p className="text-white text-[2rem] leading-[140%] font-normal">
@ -84,6 +114,20 @@ function HomeModule1() {
}
/**核心价值 */
function HomeModule2() {
const videoList = [
{
title: "Text to Movie",
video: "/assets/module2 (1).mp4",
},
{
title: "Image to Movie",
video: "/assets/module2 (2).mp4",
},
{
title: "Template to Movie",
video: "/assets/module2 (3).mp4",
},
];
return (
<div
data-alt="core-value-section"
@ -104,17 +148,29 @@ function HomeModule2() {
</p>
</div>
<div
data-alt="core-value-video"
className="relative w-[80rem] h-[45rem] flex justify-center items-center border border-white/20 rounded-lg overflow-hidden"
data-alt="core-value-videos"
className="flex justify-center gap-[1rem] w-full px-[4rem]"
>
<video
src="/assets/1.mp4"
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover"
></video>
{/* 第一个视频 */}
{videoList.map((item, index) => (
<div
data-alt="core-value-video-1"
className="flex flex-col items-center"
key={index}
>
<video
src={item.video}
autoPlay
loop
muted
playsInline
className=" h-[20rem] object-cover border border-white/20 rounded-lg"
/>
<h3 className="mt-[1rem] text-white text-[1.5rem] font-medium">
{item.title}
</h3>
</div>
))}
</div>
</div>
);
@ -168,15 +224,16 @@ function HomeModule3() {
data-alt="vertical-grid-shadow"
className="grid grid-cols-3 gap-[1rem] w-full h-[64rem] px-[5rem] relative"
>
{/* 上方阴影遮罩 - 使用 mask 实现真正的渐变模糊,加重黑色 */}
{/* 上方阴影遮罩 - 使用 mask 实现真正的渐变模糊,加重黑色 */}
<div
className="absolute -top-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none"
style={{
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
backgroundColor: 'rgba(0,0,0,0.9)',
mask: 'linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)',
WebkitMask: 'linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)'
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
backgroundColor: "rgba(0,0,0,0.9)",
mask: "linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
WebkitMask:
"linear-gradient(to bottom, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
}}
></div>
@ -184,11 +241,12 @@ function HomeModule3() {
<div
className="absolute -bottom-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none"
style={{
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
backgroundColor: 'rgba(0,0,0,0.9)',
mask: 'linear-gradient(to top, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)',
WebkitMask: 'linear-gradient(to top, black 0%, black 20%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)'
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
backgroundColor: "rgba(0,0,0,0.9)",
mask: "linear-gradient(to top, black 0%, black 30%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
WebkitMask:
"linear-gradient(to top, black 0%, black 20%, rgba(0,0,0,0.9) 50%, rgba(0,0,0,0.6) 75%, transparent 100%)",
}}
></div>
{videoList.map((column, columnIndex) => (
@ -250,24 +308,28 @@ function HomeModule4() {
const processSteps = [
{
title: "Pre-Production",
title: "The Narrative Engine",
description:
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
" From a single thought, it builds entire worlds and compelling plots.",
video: "/assets/module4 (3).mp4",
},
{
title: "Production",
title: "AI Character Engine",
description:
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
"Cast your virtual actors. Lock them in once, for the entire story.",
video: "/assets/module4 (1).mp4",
},
{
title: "Visual Effects",
title: "AI vision engine",
description:
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
"It translates your aesthetic into art, light, and cinematography for every single shot.",
video: "/assets/module4 (4).mp4",
},
{
title: "Voice",
title: "Intelligent Editing Engine",
description:
"Developing the story, writing the script, securing funding, assembling the crew, and planning the shoot.",
"An editing AI drives the final cut, for a story told seamlessly.",
video: "/assets/module4 (2).mp4",
},
];
@ -284,7 +346,7 @@ function HomeModule4() {
data-alt="core-value-content"
className="center z-10 flex flex-col items-center mb-[14rem]"
>
<h2 className="text-white text-[3.375rem] leading-[100%] font-normal mb-[3rem]">
<h2 className="text-white text-[3.375rem] leading-[100%] font-normal ">
Edit like you think
</h2>
</div>
@ -327,7 +389,7 @@ function HomeModule4() {
<div className="w-[80rem] h-[45rem] bg-gray-800 rounded-lg overflow-hidden border border-white/20">
<video
key={activeTab}
src="/assets/home.mp4"
src={processSteps[activeTab].video}
autoPlay
loop
muted
@ -342,73 +404,82 @@ function HomeModule4() {
}
/**价格方案 */
function HomeModule5() {
const [billingType, setBillingType] = useState<"monthly" | "yearly">(
"monthly"
);
const [billingType, setBillingType] = useState<"month" | "year">("month");
const pricingPlans = [
{
title: "Plus",
price: billingType === "monthly" ? 28 : 24,
credits: "1x Boost, 10 Credits",
buttonText: "Choose Plus",
features: [
"10 Credits",
"50 Video mins + 95 iStock",
"2 UGC product asset ads",
"30 secs of generative video",
"2 express clones",
"3 users, 100GB storage",
"Unlimited exports",
],
},
{
title: "Max",
price: billingType === "monthly" ? 50 : 43,
credits: "1x Boost, 40 Credits",
buttonText: "Choose Max",
features: [
"40 Credits",
"50 Video mins + 95 iStock",
"2 UGC product asset ads",
"30 secs of generative video",
"2 express clones",
"3 users, 100GB storage",
"Unlimited exports",
],
},
{
title: "Generative",
price: billingType === "monthly" ? 100 : 85,
credits: "1x Boost, 100 Credits",
buttonText: "Choose Generative",
features: [
"100 Credits",
"50 Video mins + 95 iStock",
"2 UGC product asset ads",
"30 secs of generative video",
"2 express clones",
"3 users, 100GB storage",
"Unlimited exports",
],
},
{
title: "Team",
price: billingType === "monthly" ? 899 : 764,
credits: "1x Boost, 1000 Credits",
buttonText: "Choose Team",
features: [
"1000 Credits",
"50 Video mins + 95 iStock",
"2 UGC product asset ads",
"30 secs of generative video",
"2 express clones",
"3 users, 100GB storage",
"Unlimited exports",
],
},
];
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const pathname = usePathname();
// 从后端获取订阅计划数据
useEffect(() => {
const loadPlans = async () => {
try {
const plansData = await fetchSubscriptionPlans();
setPlans(plansData);
} catch (err) {
console.error("加载订阅计划失败:", err);
}
};
loadPlans();
}, []);
const pricingPlans = useMemo<
{
title: string;
price: number;
credits: string;
buttonText: string;
features: string[];
}[]
>(() => {
return plans.map((plan) => {
return {
title: plan.display_name || plan.name,
price:
billingType === "month"
? plan.price_month / 100
: plan.price_year / 100,
credits: plan.description,
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
features: plan.features || [],
};
});
}, [plans, billingType]);
const handleSubscribe = async (planName: string) => {
if (planName === "hobby") {
return;
}
localStorage.setItem("callBackUrl", pathname);
try {
// 使用新的Checkout Session方案更简单
const { createCheckoutSession, redirectToCheckout } = await import(
"@/lib/stripe"
);
// 从localStorage获取当前用户信息
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
throw new Error("无法获取用户ID请重新登录");
}
// 1. 创建Checkout Session
const result = await createCheckoutSession({
user_id: String(User.id),
plan_name: planName,
billing_cycle: billingType,
});
if (!result.successful || !result.data) {
throw new Error("create checkout session failed");
}
// 2. 直接跳转到Stripe托管页面就这么简单
redirectToCheckout(result.data.checkout_url);
} catch (error) {
throw new Error("create checkout session failed, please try again later");
}
};
return (
<div
data-alt="core-value-section"
@ -425,9 +496,9 @@ function HomeModule5() {
{/* 计费切换 */}
<div className="h-[3.375rem] flex bg-black rounded-full p-[0.0625rem] mt-[1.5rem] border border-white/20">
<button
onClick={() => setBillingType("monthly")}
onClick={() => setBillingType("month")}
className={`box-border flex justify-center items-center w-[6rem] text-base rounded-full transition-all duration-300 ${
billingType === "monthly"
billingType === "month"
? "bg-white text-black"
: "text-white hover:text-gray-300"
}`}
@ -435,24 +506,24 @@ function HomeModule5() {
Monthly
</button>
<button
onClick={() => setBillingType("yearly")}
onClick={() => setBillingType("year")}
className={`box-border flex justify-center items-center w-[7.125rem] text-base rounded-full transition-all duration-300 ${
billingType === "yearly"
billingType === "year"
? "bg-white text-black"
: "text-white hover:text-gray-300"
}`}
>
Yearly - <span className="text-[#FFCC6D]">15%</span>
Yearly - <span className="text-[#FFCC6D]">10%</span>
</button>
</div>
</div>
{/* 主要价格卡片 */}
<div className="grid grid-cols-4 gap-[1.5rem] w-full px-[12rem] mb-[2rem]">
<div className="flex justify-between w-[90%] px-[12rem] mb-[2rem]">
{pricingPlans.map((plan, index) => (
<div
key={index}
className=" h-[38.125rem] bg-black rounded-lg p-[1.5rem] border border-white/20"
className=" w-[24rem] h-[38.125rem] bg-black rounded-lg p-[1.5rem] border border-white/20"
>
<h3 className="text-white text-2xl font-normal mb-[1rem]">
{plan.title}
@ -466,7 +537,10 @@ function HomeModule5() {
<p className="text-white text-[0.875rem] mb-[1rem]">
{plan.credits}
</p>
<button className="w-full bg-white text-black py-[0.75rem] rounded-full mb-[1rem] hover:bg-black hover:text-white transition-colors border border-white/20">
<button
onClick={() => handleSubscribe(plan.title)}
className="w-full bg-white text-black py-[0.75rem] rounded-full mb-[1rem] hover:bg-black hover:text-white transition-colors border border-white/20"
>
{plan.buttonText}
</button>
<p className="w-full text-center text-white/60 text-[0.75rem] mb-[2rem]">
@ -488,7 +562,7 @@ function HomeModule5() {
</div>
{/* 额外价格卡片 */}
<div className="flex gap-[1.5rem] w-full px-[12rem]">
<div className="flex gap-[1.5rem] w-[90%] px-[12rem]">
<div className="flex-1 bg-black rounded-lg p-[1.5rem] border border-white/20">
<h3 className="text-white text-[1.5rem] font-bold mb-[0.5rem]">
Free

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.