forked from 77media/video-flow
say hello to pay system
This commit is contained in:
parent
eccb0a3e53
commit
e57d9e6c6f
@ -19,6 +19,10 @@ export default function DashboardPage() {
|
|||||||
const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false);
|
const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false);
|
||||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||||
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking');
|
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking');
|
||||||
|
|
||||||
|
// 支付成功状态
|
||||||
|
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false);
|
||||||
|
const [paymentData, setPaymentData] = useState<any>(null);
|
||||||
|
|
||||||
// 使用 ref 来存储最新的状态,避免定时器闭包问题
|
// 使用 ref 来存储最新的状态,避免定时器闭包问题
|
||||||
const stateRef = useRef({ isUsingMockData, dashboardData });
|
const stateRef = useRef({ isUsingMockData, dashboardData });
|
||||||
@ -31,6 +35,40 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 检测支付成功
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
const payment = searchParams.get('payment');
|
||||||
|
|
||||||
|
if (sessionId && payment === 'success') {
|
||||||
|
// 显示支付成功提示
|
||||||
|
setShowPaymentSuccess(true);
|
||||||
|
|
||||||
|
// 获取支付详情
|
||||||
|
fetchPaymentDetails(sessionId);
|
||||||
|
|
||||||
|
// 清除URL参数,避免刷新页面时重复显示
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.delete('session_id');
|
||||||
|
newUrl.searchParams.delete('payment');
|
||||||
|
window.history.replaceState({}, '', newUrl.pathname);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 获取支付详情
|
||||||
|
const fetchPaymentDetails = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/payment/checkout-status/${sessionId}?user_id=test_user_123`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.successful && result.data) {
|
||||||
|
setPaymentData(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取支付详情失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 初始加载数据
|
// 初始加载数据
|
||||||
const fetchInitialData = async () => {
|
const fetchInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
@ -370,6 +408,35 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex flex-col overflow-hidden">
|
<div className="h-full bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex flex-col overflow-hidden">
|
||||||
|
{/* 支付成功提示 */}
|
||||||
|
{showPaymentSuccess && paymentData && (
|
||||||
|
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-green-800">支付成功!</h3>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
您的订阅已激活,订单号: {paymentData.biz_order_no}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto pl-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPaymentSuccess(false)}
|
||||||
|
className="text-green-400 hover:text-green-600"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 后台刷新指示器 - 优化用户体验 */}
|
{/* 后台刷新指示器 - 优化用户体验 */}
|
||||||
{isBackgroundRefreshing && (
|
{isBackgroundRefreshing && (
|
||||||
<div className="fixed top-4 right-4 z-50 bg-blue-500/90 backdrop-blur-sm text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 max-w-xs">
|
<div className="fixed top-4 right-4 z-50 bg-blue-500/90 backdrop-blur-sm text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 max-w-xs">
|
||||||
|
|||||||
236
app/payment-success/page.tsx
Normal file
236
app/payment-success/page.tsx
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface PaymentStatus {
|
||||||
|
payment_status: 'pending' | 'success' | 'fail';
|
||||||
|
biz_order_no: string;
|
||||||
|
pay_time?: string;
|
||||||
|
subscription?: {
|
||||||
|
plan_name: string;
|
||||||
|
plan_display_name: string;
|
||||||
|
status: string;
|
||||||
|
current_period_end?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentSuccessPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'failed' | 'timeout'>('loading');
|
||||||
|
const [paymentData, setPaymentData] = useState<PaymentStatus | null>(null);
|
||||||
|
const [attempts, setAttempts] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setStatus('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollPaymentStatus = async () => {
|
||||||
|
const maxAttempts = 30; // 最多轮询30次
|
||||||
|
const interval = 2000; // 每2秒轮询一次
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
setAttempts(i + 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用新的Checkout Session状态查询
|
||||||
|
const { getCheckoutSessionStatus } = await import('@/lib/stripe');
|
||||||
|
const result = await getCheckoutSessionStatus(sessionId, 'test_user_123'); // 临时测试用户ID
|
||||||
|
|
||||||
|
if (result.successful && result.data) {
|
||||||
|
setPaymentData(result.data);
|
||||||
|
|
||||||
|
if (result.data.payment_status === 'success') {
|
||||||
|
setStatus('success');
|
||||||
|
return;
|
||||||
|
} else if (result.data.payment_status === 'fail') {
|
||||||
|
setStatus('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待下次轮询
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('轮询Checkout Session状态失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询超时
|
||||||
|
setStatus('timeout');
|
||||||
|
};
|
||||||
|
|
||||||
|
pollPaymentStatus();
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'loading':
|
||||||
|
return (
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<Loader2 className="w-16 h-16 text-blue-500 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>处理中...</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
正在确认您的支付,请稍候
|
||||||
|
<br />
|
||||||
|
尝试次数: {attempts}/30
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
请不要关闭此页面,我们正在处理您的订阅
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'success':
|
||||||
|
return (
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-green-600">支付成功!</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
您的订阅已激活
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{paymentData?.subscription && (
|
||||||
|
<div className="bg-green-50 p-4 rounded-lg">
|
||||||
|
<h3 className="font-semibold text-green-800">
|
||||||
|
{paymentData.subscription.plan_display_name} 套餐
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
状态: {paymentData.subscription.status}
|
||||||
|
</p>
|
||||||
|
{paymentData.subscription.current_period_end && (
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
有效期至: {new Date(paymentData.subscription.current_period_end).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p>订单号: {paymentData?.biz_order_no}</p>
|
||||||
|
{paymentData?.pay_time && (
|
||||||
|
<p>支付时间: {new Date(paymentData.pay_time).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
前往控制台
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
return (
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<XCircle className="w-16 h-16 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-red-600">支付失败</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
很抱歉,您的支付未能成功完成
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
请检查您的支付信息或稍后重试
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/pricing'}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
重新选择套餐
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'timeout':
|
||||||
|
return (
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<Loader2 className="w-16 h-16 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-yellow-600">处理中</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
支付正在处理中,请稍后查看
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
您的支付可能仍在处理中,请稍后检查您的订阅状态
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
查看订阅状态
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
app/pricing/page.tsx
Normal file
282
app/pricing/page.tsx
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Check, ArrowLeft } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { fetchSubscriptionPlans, SubscriptionPlan } from '@/lib/stripe';
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
|
||||||
|
// 从后端获取订阅计划数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPlans = async () => {
|
||||||
|
try {
|
||||||
|
const plansData = await fetchSubscriptionPlans();
|
||||||
|
setPlans(plansData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载订阅计划失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 转换后端数据为前端显示格式,保持原有的数据结构
|
||||||
|
const transformPlanForDisplay = (plan: SubscriptionPlan) => {
|
||||||
|
const monthlyPrice = plan.price_monthly / 100; // 后端存储的是分,转换为元
|
||||||
|
const yearlyPrice = plan.price_yearly / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: plan.name,
|
||||||
|
displayName: plan.display_name,
|
||||||
|
price: {
|
||||||
|
monthly: monthlyPrice,
|
||||||
|
yearly: 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 handleSubscribe = async (planName: string) => {
|
||||||
|
if (planName === 'hobby') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: billingCycle
|
||||||
|
});
|
||||||
|
|
||||||
|
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('yearly')}
|
||||||
|
className={`flex items-center justify-center px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||||
|
billingCycle === 'yearly'
|
||||||
|
? '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 === 'yearly'
|
||||||
|
? 'text-white/90'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
Up to 20% off 🔥
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
className={`px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||||
|
billingCycle === 'monthly'
|
||||||
|
? '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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Billing Toggle */}
|
||||||
|
<div className="inline-flex items-center bg-gray-900/50 rounded-full p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
className={`flex items-center justify-center px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||||
|
billingCycle === 'yearly'
|
||||||
|
? '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 === 'yearly'
|
||||||
|
? 'text-white/90'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}`}>
|
||||||
|
Up to 20% off 🔥
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
className={`px-6 py-2.5 rounded-full transition-all duration-300 ease-out font-medium text-base ${
|
||||||
|
billingCycle === 'monthly'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
|
||||||
|
: 'text-gray-300 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Pay Monthly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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 === 'monthly' ? '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>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -73,6 +73,16 @@ export function TopBar({ collapsed, onToggleSidebar }: { collapsed: boolean; onT
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Pricing Link */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/pricing')}
|
||||||
|
className="text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Table, AlignHorizontalSpaceAround, Loader2, Clapperboard } from "lucide-react";
|
import { Table, AlignHorizontalSpaceAround, Loader2, Clapperboard, CreditCard } from "lucide-react";
|
||||||
import "./style/home-page2.css";
|
import "./style/home-page2.css";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { VideoCarouselLayout } from '@/components/video-carousel-layout';
|
import { VideoCarouselLayout } from '@/components/video-carousel-layout';
|
||||||
import { VideoGridLayout } from '@/components/video-grid-layout';
|
import { VideoGridLayout } from '@/components/video-grid-layout';
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@ -24,6 +24,7 @@ import {
|
|||||||
|
|
||||||
export function HomePage2() {
|
export function HomePage2() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [activeTool, setActiveTool] = useState("stretch");
|
const [activeTool, setActiveTool] = useState("stretch");
|
||||||
const [dropPosition, setDropPosition] = useState<"left" | "right">("left");
|
const [dropPosition, setDropPosition] = useState<"left" | "right">("left");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@ -31,6 +32,10 @@ export function HomePage2() {
|
|||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 支付成功状态
|
||||||
|
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false);
|
||||||
|
const [paymentData, setPaymentData] = useState<any>(null);
|
||||||
|
|
||||||
// 将资源数据转换为视频格式
|
// 将资源数据转换为视频格式
|
||||||
const videos = resources.map(resource => ({
|
const videos = resources.map(resource => ({
|
||||||
@ -57,6 +62,40 @@ export function HomePage2() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 检测支付成功
|
||||||
|
useEffect(() => {
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
const payment = searchParams.get('payment');
|
||||||
|
|
||||||
|
if (sessionId && payment === 'success') {
|
||||||
|
// 显示支付成功提示
|
||||||
|
setShowPaymentSuccess(true);
|
||||||
|
|
||||||
|
// 获取支付详情
|
||||||
|
fetchPaymentDetails(sessionId);
|
||||||
|
|
||||||
|
// 清除URL参数,避免刷新页面时重复显示
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.delete('session_id');
|
||||||
|
newUrl.searchParams.delete('payment');
|
||||||
|
window.history.replaceState({}, '', newUrl.pathname);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 获取支付详情
|
||||||
|
const fetchPaymentDetails = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/payment/checkout-status/${sessionId}?user_id=test_user_123`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.successful && result.data) {
|
||||||
|
setPaymentData(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取支付详情失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 组件挂载时获取资源
|
// 组件挂载时获取资源
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchResources();
|
fetchResources();
|
||||||
@ -119,6 +158,35 @@ export function HomePage2() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]" ref={containerRef}>
|
<div className="min-h-screen bg-[var(--background)]" ref={containerRef}>
|
||||||
|
{/* 支付成功提示 */}
|
||||||
|
{showPaymentSuccess && paymentData && (
|
||||||
|
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-green-800">支付成功!</h3>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
您的订阅已激活,订单号: {paymentData.biz_order_no}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto pl-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPaymentSuccess(false)}
|
||||||
|
className="text-green-400 hover:text-green-600"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex relative" style={{height: '100vh'}}>
|
<div className="flex relative" style={{height: '100vh'}}>
|
||||||
{/* 工具栏-列表形式切换 */}
|
{/* 工具栏-列表形式切换 */}
|
||||||
<div className="absolute top-[8rem] z-[50] right-6 w-[128px] flex justify-end">
|
<div className="absolute top-[8rem] z-[50] right-6 w-[128px] flex justify-end">
|
||||||
@ -188,6 +256,20 @@ export function HomePage2() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing 入口 */}
|
||||||
|
<div className="fixed bottom-[3rem] right-[3rem] z-50">
|
||||||
|
<LiquidButton className="w-[120px] h-[48px] text-sm">
|
||||||
|
<div className="flex items-center justify-center gap-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push('/pricing');
|
||||||
|
}}>
|
||||||
|
<CreditCard className="w-5 h-5 text-white" />
|
||||||
|
Pricing
|
||||||
|
</div>
|
||||||
|
</LiquidButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-[128px] h-[128px] rounded-[50%] overflow-hidden fixed bottom-[3rem] left-[50%] -translate-x-1/2 z-50">
|
<div className="w-[128px] h-[128px] rounded-[50%] overflow-hidden fixed bottom-[3rem] left-[50%] -translate-x-1/2 z-50">
|
||||||
<LiquidButton className="w-[128px] h-[128px] text-lg">
|
<LiquidButton className="w-[128px] h-[128px] text-lg">
|
||||||
<div className="flex items-center justify-center gap-2"
|
<div className="flex items-center justify-center gap-2"
|
||||||
|
|||||||
114
lib/stripe.ts
Normal file
114
lib/stripe.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Stripe 支付相关工具函数
|
||||||
|
*/
|
||||||
|
import { post, get } from '@/api/request';
|
||||||
|
import { ApiResponse } from '@/api/common';
|
||||||
|
|
||||||
|
export interface SubscriptionPlan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
price_monthly: number;
|
||||||
|
price_yearly: number;
|
||||||
|
features: string[];
|
||||||
|
is_free: boolean;
|
||||||
|
is_popular: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStatusData {
|
||||||
|
payment_status: 'pending' | 'success' | 'fail';
|
||||||
|
biz_order_no: string;
|
||||||
|
pay_time?: string;
|
||||||
|
subscription?: {
|
||||||
|
plan_name: string;
|
||||||
|
plan_display_name: string;
|
||||||
|
status: string;
|
||||||
|
current_period_end?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentStatusResponse = ApiResponse<PaymentStatusData>;
|
||||||
|
|
||||||
|
export interface CreateCheckoutSessionRequest {
|
||||||
|
user_id: string;
|
||||||
|
plan_name: string;
|
||||||
|
billing_cycle: 'monthly' | 'yearly';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCheckoutSessionData {
|
||||||
|
checkout_url: string;
|
||||||
|
session_id: string;
|
||||||
|
biz_order_no: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateCheckoutSessionResponse = ApiResponse<CreateCheckoutSessionData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订阅计划列表
|
||||||
|
* 从后端API获取所有活跃的订阅计划,后端已经过滤了活跃计划
|
||||||
|
*/
|
||||||
|
export async function fetchSubscriptionPlans(): Promise<SubscriptionPlan[]> {
|
||||||
|
try {
|
||||||
|
const response = await get<ApiResponse<SubscriptionPlan[]>>('/api/subscription/plans');
|
||||||
|
|
||||||
|
if (!response.successful || !response.data) {
|
||||||
|
throw new Error(response.message || '获取订阅计划失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端已经过滤了活跃计划,直接按排序顺序排列
|
||||||
|
const sortedPlans = response.data.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
|
||||||
|
return sortedPlans;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取订阅计划失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Checkout Session(推荐方案)
|
||||||
|
*
|
||||||
|
* 这是更简单的支付方案:
|
||||||
|
* 1. 调用此函数获取checkout_url
|
||||||
|
* 2. 直接跳转到checkout_url
|
||||||
|
* 3. 用户在Stripe页面完成支付
|
||||||
|
* 4. 支付成功后自动跳转回success_url
|
||||||
|
*/
|
||||||
|
export async function createCheckoutSession(
|
||||||
|
request: CreateCheckoutSessionRequest
|
||||||
|
): Promise<CreateCheckoutSessionResponse> {
|
||||||
|
try {
|
||||||
|
return await post<CreateCheckoutSessionResponse>('/api/payment/checkoutDeepControl', request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建Checkout Session失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询Checkout Session状态
|
||||||
|
*/
|
||||||
|
export async function getCheckoutSessionStatus(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<PaymentStatusResponse> {
|
||||||
|
try {
|
||||||
|
return await get<PaymentStatusResponse>(`/api/payment/checkout-status/${sessionId}?user_id=${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查询Checkout Session状态失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的跳转到Checkout页面的工具函数
|
||||||
|
*/
|
||||||
|
export function redirectToCheckout(checkoutUrl: string) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = checkoutUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user