Skip to content

Commit

Permalink
feat(auth): 添加订阅管理功能,包括订阅计划和订阅购买
Browse files Browse the repository at this point in the history
  • Loading branch information
zyh320888 committed Oct 22, 2024
1 parent 375a302 commit b0bae22
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 53 deletions.
140 changes: 87 additions & 53 deletions app/components/auth/SubscriptionDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,86 @@
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useAuth } from '~/hooks/useAuth';
import { toast } from 'react-toastify';

interface SubscriptionDialogProps {
isOpen: boolean;
onClose: () => void;
}

interface SubscriptionPlan {
_id: number;
name: string;
tokens: number;
price: number;
description: string;
savePercentage?: number;
save_percentage?: number;
}

const subscriptionPlans: SubscriptionPlan[] = [
{
name: "专业版",
tokens: 10000000,
price: 20,
description: "适合业余爱好者和轻度用户进行探索性使用。"
},
{
name: "专业版 50",
tokens: 26000000,
price: 50,
description: "为每周需要使用多八多几次的专业人士设计。",
savePercentage: 3
},
{
name: "专业版 100",
tokens: 55000000,
price: 100,
description: "适合希望提升日常工作流程的重度用户。",
savePercentage: 9
},
{
name: "专业版 200",
tokens: 120000000,
price: 200,
description: "最适合将多八多作为核心工具持续使用的超级用户。",
savePercentage: 17
}
];
interface UserSubscription {
plan: SubscriptionPlan;
tokensLeft: number;
nextReloadDate: string;
}

export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps) {
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([]);
const [userSubscription, setUserSubscription] = useState<UserSubscription | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { user } = useAuth();

if (!user) return null;
useEffect(() => {
if (isOpen && user) {
fetchSubscriptionData();
}
}, [isOpen, user]);

const fetchSubscriptionData = async () => {
setIsLoading(true);
try {
const [plansResponse, userSubResponse] = await Promise.all([
fetch('/api/subscription-plans'),
fetch('/api/user-subscription')
]);
const plans = await plansResponse.json();
const userSub = await userSubResponse.json();
setSubscriptionPlans(plans);
setUserSubscription(userSub);
} catch (error) {
console.error('Error fetching subscription data:', error);
toast.error('获取订阅信息失败,请稍后重试。');
} finally {
setIsLoading(false);
}
};

const currentPlan = subscriptionPlans[1]; // 假设当前用户使用的是"专业版 50"
const handlePurchase = async (planId: number) => {
try {
const response = await fetch('/api/purchase-subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
planId,
billingCycle,
}),
});
const result = await response.json();
if (response.ok) {
toast.success('订阅购买成功!');
fetchSubscriptionData(); // 刷新订阅信息
} else {
toast.error(result.message || '购买失败,请稍后重试。');
}
} catch (error) {
console.error('Error purchasing subscription:', error);
toast.error('购买过程中出现错误,请稍后重试。');
}
};

if (!user || isLoading) return null;

return (
<DialogRoot open={isOpen}>
Expand All @@ -65,23 +94,27 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)
</p>
</div>

<div className="bg-bolt-elements-background-depth-2 p-4 rounded-lg">
<div className="flex justify-between items-center">
<div>
<span className="text-bolt-elements-textPrimary font-bold">300万</span>
<span className="text-bolt-elements-textSecondary"> 代币剩余。</span>
<span className="text-bolt-elements-textSecondary">2600万代币将在17天后添加。</span>
</div>
<div className="text-right">
<span className="text-bolt-elements-textSecondary">需要更多代币?</span>
<br />
<span className="text-bolt-elements-textSecondary">
升级您的计划或购买
<a href="#" className="text-bolt-elements-item-contentAccent hover:underline">代币充值包</a>
</span>
{userSubscription && (
<div className="bg-bolt-elements-background-depth-2 p-4 rounded-lg">
<div className="flex justify-between items-center">
<div>
<span className="text-bolt-elements-textPrimary font-bold">{userSubscription.tokensLeft.toLocaleString()}</span>
<span className="text-bolt-elements-textSecondary"> 代币剩余。</span>
<span className="text-bolt-elements-textSecondary">
{userSubscription.plan.tokens.toLocaleString()}代币将在{new Date(userSubscription.nextReloadDate).toLocaleDateString()}后添加。
</span>
</div>
<div className="text-right">
<span className="text-bolt-elements-textSecondary">需要更多代币?</span>
<br />
<span className="text-bolt-elements-textSecondary">
升级您的计划或购买
<a href="#" className="text-bolt-elements-item-contentAccent hover:underline">代币充值包</a>
</span>
</div>
</div>
</div>
</div>
)}

<div className="flex justify-center space-x-4">
<button
Expand All @@ -108,26 +141,27 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{subscriptionPlans.map((plan) => (
<div key={plan.name} className={`bg-bolt-elements-background-depth-2 p-4 rounded-lg ${plan.name === currentPlan.name ? 'border-2 border-bolt-elements-item-contentAccent' : ''}`}>
<div key={plan._id} className={`bg-bolt-elements-background-depth-2 p-4 rounded-lg ${plan._id === userSubscription?.plan._id ? 'border-2 border-bolt-elements-item-contentAccent' : ''}`}>
<h3 className="text-bolt-elements-textPrimary font-bold text-lg">{plan.name}</h3>
<div className="text-bolt-elements-textSecondary mb-2">
{(plan.tokens / 1000000).toFixed(0)}M 代币
{plan.savePercentage && (
<span className="ml-2 text-green-500">节省 {plan.savePercentage}%</span>
{plan.save_percentage && (
<span className="ml-2 text-green-500">节省 {plan.save_percentage}%</span>
)}
</div>
<p className="text-bolt-elements-textTertiary text-sm mb-4">{plan.description}</p>
<div className="text-bolt-elements-textPrimary font-bold text-2xl mb-2">
¥{plan.price * (billingCycle === 'yearly' ? 10 : 1)}/{billingCycle === 'yearly' ? '年' : '月'}
</div>
<button
onClick={() => handlePurchase(plan._id)}
className={`w-full py-2 rounded-md ${
plan.name === currentPlan.name
plan._id === userSubscription?.plan._id
? 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
: 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
}`}
>
{plan.name === currentPlan.name ? '管理当前计划' : `升级到${plan.name}`}
{plan._id === userSubscription?.plan._id ? '管理当前计划' : `升级到${plan.name}`}
</button>
</div>
))}
Expand Down
11 changes: 11 additions & 0 deletions app/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { LoginDialog } from '~/components/auth/LoginDialog';
import { RegisterDialog } from '~/components/auth/RegisterDialog';
import { useAuth } from '~/hooks/useAuth';
import { UserMenu } from './UserMenu';
import { SubscriptionDialog } from '~/components/auth/SubscriptionDialog';

export function Header() {
const chat = useStore(chatStore);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const { isAuthenticated } = useAuth();
const [isSubscriptionOpen, setIsSubscriptionOpen] = useState(false);

return (
<header
Expand Down Expand Up @@ -63,10 +65,19 @@ export function Header() {
</button>
</>
)}
{isAuthenticated && (
<button
onClick={() => setIsSubscriptionOpen(true)}
className="px-4 py-2 text-sm font-medium text-bolt-elements-button-secondary-text bg-bolt-elements-button-secondary-background hover:bg-bolt-elements-button-secondary-backgroundHover rounded-md transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background"
>
订阅管理
</button>
)}
</div>

<LoginDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
<RegisterDialog isOpen={isRegisterOpen} onClose={() => setIsRegisterOpen(false)} />
<SubscriptionDialog isOpen={isSubscriptionOpen} onClose={() => setIsSubscriptionOpen(false)} />
</header>
);
}
42 changes: 42 additions & 0 deletions app/routes/api.purchase-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { json } from '@remix-run/cloudflare';
import { db } from '~/lib/db.server';
import { requireUserId } from '~/lib/session.server';

export async function action({ request }) {
const userId = await requireUserId(request);
const { planId, billingCycle } = await request.json();

try {
// 开始数据库事务
await db.transaction(async (trx) => {
// 获取订阅计划详情
const plan = await trx('subscription_plans').where('_id', planId).first();
if (!plan) {
throw new Error('Invalid subscription plan');
}

// 计算实际价格和代币数量
const price = billingCycle === 'yearly' ? plan.price * 10 : plan.price;
const tokens = billingCycle === 'yearly' ? plan.tokens * 12 : plan.tokens;

// 创建交易记录
await trx('user_transactions').insert({
user_id: userId,
type: 'subscription',
plan_id: planId,
amount: price,
tokens: tokens,
status: 'completed', // 假设支付已完成
payment_method: 'credit_card', // 假设使用信用卡支付
transaction_id: `sub_${Date.now()}`, // 生成一个简单的交易ID
});

// 这里可以添加更多逻辑,如更新用户的订阅状态等
});

return json({ success: true, message: '订阅购买成功' });
} catch (error) {
console.error('Error purchasing subscription:', error);
return json({ error: 'Failed to purchase subscription' }, { status: 500 });
}
}
12 changes: 12 additions & 0 deletions app/routes/api.subscription-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { json } from '@remix-run/cloudflare';
import { db } from '~/lib/db.server';

export async function loader() {
try {
const plans = await db.select().from('subscription_plans').where('is_active', true);
return json(plans);
} catch (error) {
console.error('Error fetching subscription plans:', error);
return json({ error: 'Failed to fetch subscription plans' }, { status: 500 });
}
}
24 changes: 24 additions & 0 deletions app/routes/api.user-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { json } from '@remix-run/cloudflare';
import { db } from '~/lib/db.server';
import { requireUserId } from '~/lib/session.server';

export async function loader({ request }) {
const userId = await requireUserId(request);
try {
const userSubscription = await db.select(
'subscription_plans.*',
'user_transactions.tokens as tokensLeft',
db.raw('DATE_ADD(user_transactions._create, INTERVAL 1 MONTH) as nextReloadDate')
)
.from('user_transactions')
.join('subscription_plans', 'user_transactions.plan_id', 'subscription_plans._id')
.where('user_transactions.user_id', userId)
.orderBy('user_transactions._create', 'desc')
.first();

return json(userSubscription);
} catch (error) {
console.error('Error fetching user subscription:', error);
return json({ error: 'Failed to fetch user subscription' }, { status: 500 });
}
}

0 comments on commit b0bae22

Please sign in to comment.