Skip to content

Commit

Permalink
feat: 添加订阅购买API功能
Browse files Browse the repository at this point in the history
  • Loading branch information
zyh320888 committed Oct 22, 2024
1 parent b0bae22 commit 7c0932e
Show file tree
Hide file tree
Showing 13 changed files with 499 additions and 121 deletions.
1 change: 1 addition & 0 deletions app/components/auth/PaymentDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

87 changes: 87 additions & 0 deletions app/components/auth/PaymentModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
import { toast } from 'react-toastify';

interface PaymentModalProps {
isOpen: boolean;
onClose: () => void;
paymentData: PaymentResponse;
onPaymentSuccess: () => void;
}

interface PaymentResponse {
status: string;
msg: string;
no: string;
pay_type: string;
order_amount: string;
pay_amount: string;
qr_money: string;
qr: string;
qr_img: string;
did: string;
expires_in: string;
return_url: string;
}

export function PaymentModal({ isOpen, onClose, paymentData, onPaymentSuccess }: PaymentModalProps) {
const [timeLeft, setTimeLeft] = useState(parseInt(paymentData.expires_in));

const checkPaymentStatus = useCallback(async () => {
try {
const response = await fetch(`/api/check-payment-status?orderNo=${paymentData.no}`);
const data = await response.json();
if (data.status === 'completed') {
clearInterval(timer);
onPaymentSuccess();
onClose();
toast.success('支付成功!');
}
} catch (error) {
console.error('Error checking payment status:', error);
}
}, [paymentData.no, onPaymentSuccess, onClose]);

useEffect(() => {
if (!isOpen) return;

const timer = setInterval(() => {
setTimeLeft((prevTime) => {
if (prevTime <= 1) {
clearInterval(timer);
onClose();
toast.error('支付超时,请重新发起支付');
return 0;
}
return prevTime - 1;
});

checkPaymentStatus();
}, 3000); // 每3秒检查一次支付状态

return () => clearInterval(timer);
}, [isOpen, onClose, checkPaymentStatus]);

return (
<DialogRoot open={isOpen}>
<Dialog onBackdrop={onClose} onClose={onClose} className="w-full max-w-md">
<DialogTitle>请扫码支付</DialogTitle>
<DialogDescription>
<div className="space-y-4">
<div className="text-center">
<img src={paymentData.qr_img} alt="支付二维码" className="mx-auto" />
</div>
<div className="text-center">
<p className="text-bolt-elements-textPrimary">订单金额: ¥{paymentData.order_amount}</p>
<p className="text-bolt-elements-textSecondary">订单号: {paymentData.no}</p>
<p className="text-bolt-elements-textSecondary">支付方式: {paymentData.pay_type}</p>
</div>
<div className="text-center">
<p className="text-bolt-elements-textPrimary">剩余支付时间: {timeLeft}</p>
</div>
</div>
</DialogDescription>
</Dialog>
</DialogRoot>
);
}
203 changes: 117 additions & 86 deletions app/components/auth/SubscriptionDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Dialog, DialogTitle, DialogDescription, DialogRoot } from '~/components/ui/Dialog';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '~/hooks/useAuth';
import { toast } from 'react-toastify';
import { PaymentModal } from './PaymentModal';

interface SubscriptionDialogProps {
isOpen: boolean;
Expand All @@ -23,12 +24,28 @@ interface UserSubscription {
nextReloadDate: string;
}

interface PaymentResponse {
status: string;
msg: string;
no: string;
pay_type: string;
order_amount: string;
pay_amount: string;
qr_money: string;
qr: string;
qr_img: string;
did: string;
expires_in: string;
return_url: 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();
const [paymentData, setPaymentData] = useState<PaymentResponse | null>(null);

useEffect(() => {
if (isOpen && user) {
Expand Down Expand Up @@ -68,107 +85,121 @@ export function SubscriptionDialog({ isOpen, onClose }: SubscriptionDialogProps)
}),
});
const result = await response.json();
if (response.ok) {
toast.success('订阅购买成功!');
fetchSubscriptionData(); // 刷新订阅信息
if (response.ok && result.paymentData) {
setPaymentData(result.paymentData);
} else {
toast.error(result.message || '购买失败,请稍后重试。');
toast.error(result.message || '获取支付信息失败,请稍后重试。');
}
} catch (error) {
console.error('Error purchasing subscription:', error);
console.error('Error initiating purchase:', error);
toast.error('购买过程中出现错误,请稍后重试。');
}
};

const handlePaymentSuccess = useCallback(() => {
fetchSubscriptionData(); // 重新获取订阅信息
toast.success('订阅成功!');
}, [fetchSubscriptionData]);

if (!user || isLoading) return null;

return (
<DialogRoot open={isOpen}>
<Dialog onBackdrop={onClose} onClose={onClose} className="w-full max-w-4xl">
<DialogTitle>订阅管理</DialogTitle>
<DialogDescription>
<div className="space-y-6">
<div className="text-center">
<p className="text-bolt-elements-textSecondary">
注册免费账户以加速您在公共项目上的工作流程,或通过即时打开的生产环境提升整个团队的效率。
</p>
</div>
<>
<DialogRoot open={isOpen}>
<Dialog onBackdrop={onClose} onClose={onClose} className="w-full max-w-4xl">
<DialogTitle>订阅管理</DialogTitle>
<DialogDescription>
<div className="space-y-6">
<div className="text-center">
<p className="text-bolt-elements-textSecondary">
注册免费账户以加速您在公共项目上的工作流程,或通过即时打开的生产环境提升整个团队的效率。
</p>
</div>

{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>
{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
onClick={() => setBillingCycle('monthly')}
className={`px-4 py-2 rounded-md ${
billingCycle === 'monthly'
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
}`}
>
月付
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-4 py-2 rounded-md ${
billingCycle === 'yearly'
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
}`}
>
年付
</button>
</div>
<div className="flex justify-center space-x-4">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-4 py-2 rounded-md ${
billingCycle === 'monthly'
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
}`}
>
月付
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-4 py-2 rounded-md ${
billingCycle === 'yearly'
? 'bg-bolt-elements-button-primary-background text-bolt-elements-button-primary-text'
: 'bg-bolt-elements-button-secondary-background text-bolt-elements-button-secondary-text'
}`}
>
年付
</button>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{subscriptionPlans.map((plan) => (
<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.save_percentage && (
<span className="ml-2 text-green-500">节省 {plan.save_percentage}%</span>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{subscriptionPlans.map((plan) => (
<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.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._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._id === userSubscription?.plan._id ? '管理当前计划' : `升级到${plan.name}`}
</button>
</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._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._id === userSubscription?.plan._id ? '管理当前计划' : `升级到${plan.name}`}
</button>
</div>
))}
))}
</div>
</div>
</div>
</DialogDescription>
</Dialog>
</DialogRoot>
</DialogDescription>
</Dialog>
</DialogRoot>
{paymentData && (
<PaymentModal
isOpen={!!paymentData}
onClose={() => setPaymentData(null)}
paymentData={paymentData}
onPaymentSuccess={handlePaymentSuccess}
/>
)}
</>
);
}
26 changes: 26 additions & 0 deletions app/routes/api.check-payment-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { json } from '@remix-run/cloudflare';
import { db } from '~/utils/db.server';

export async function loader({ request }: { request: Request }) {
const url = new URL(request.url);
const orderNo = url.searchParams.get('orderNo');

if (!orderNo) {
return json({ error: 'Order number is required' }, { status: 400 });
}

try {
const transaction = await db('user_transactions')
.where('transaction_id', orderNo)
.first();

if (!transaction) {
return json({ error: 'Transaction not found' }, { status: 404 });
}

return json({ status: transaction.status });
} catch (error) {
console.error('Error checking payment status:', error);
return json({ error: 'Failed to check payment status' }, { status: 500 });
}
}
Loading

0 comments on commit 7c0932e

Please sign in to comment.