2025-08-27 23:56:27 +08:00

283 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { 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<'month' | 'year'>('month');
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_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 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('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"
>
<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('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>
{/* 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>
</main>
</div>
);
}