feat(subscription): Refactor subscription handling and update imports for better organization

This commit is contained in:
web@ppanel 2025-03-08 12:34:23 +07:00
parent 3222016799
commit 2215c7f2b9
27 changed files with 1042 additions and 180 deletions

View File

@ -1,49 +1,43 @@
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.26](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.25...v1.0.0-beta.26) (2025-03-02)
### 🐛 Bug Fixes
* **icon**: Comment out unused icon collection imports ([f17bf8d](https://github.com/perfect-panel/ppanel-web/commit/f17bf8d))
- **icon**: Comment out unused icon collection imports ([f17bf8d](https://github.com/perfect-panel/ppanel-web/commit/f17bf8d))
# [1.0.0-beta.25](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.24...v1.0.0-beta.25) (2025-03-01)
### ✨ Features
* **auth**: Add privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))
- **auth**: Add privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))
### 🐛 Bug Fixes
* **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
* **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
* **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
- **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
- **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
- **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
# [1.0.0-beta.24](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.23...v1.0.0-beta.24) (2025-02-27)
### ♻ Code Refactoring
* **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
* Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))
- **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
- Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))
### ✨ Features
* **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))
- **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))
### 🎫 Chores
* **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))
- **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))
### 🐛 Bug Fixes
* **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
- **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
<a name="readme-top"></a>

View File

@ -115,7 +115,7 @@ export function SubscriptionDetail({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
} as API.GetUserSubscribeTrafficLogsParams);
return {
list: data.data?.list || [],
total: data.data?.total || 0,

View File

@ -120,17 +120,6 @@ declare namespace API {
ids: number[];
};
type CheckoutOrderRequest = {
orderNo: string;
returnUrl?: string;
};
type CheckoutOrderResponse = {
type: string;
checkout_url?: string;
stripe?: StripePayment;
};
type CloseOrderRequest = {
orderNo: string;
};
@ -488,7 +477,7 @@ declare namespace API {
};
type GetAvailablePaymentMethodsResponse = {
list: PaymentConfig[];
list: PaymenMethod[];
};
type GetCouponListParams = {
@ -797,6 +786,8 @@ declare namespace API {
size: number;
user_id: number;
subscribe_id: number;
start_time: number;
end_time: number;
};
type GetUserSubscribeTrafficLogsRequest = {
@ -804,6 +795,8 @@ declare namespace API {
size: number;
user_id: number;
subscribe_id: number;
start_time: number;
end_time: number;
};
type GetUserSubscribeTrafficLogsResponse = {
@ -931,6 +924,16 @@ declare namespace API {
list?: OrdersStatistics[];
};
type PaymenMethod = {
id: number;
name: string;
mark: string;
icon: string;
fee_mode: number;
fee_percent: number;
fee_amount: number;
};
type PaymentConfig = {
id: number;
name: string;
@ -1029,6 +1032,21 @@ declare namespace API {
list: OrderDetail[];
};
type QuerySubscribeGroupListResponse = {
list: SubscribeGroup[];
total: number;
};
type QuerySubscribeListResponse = {
list: Subscribe[];
total: number;
};
type QueryUserAffiliateListResponse = {
list: UserAffiliate[];
total: number;
};
type RechargeOrderRequest = {
amount: number;
payment: string;

View File

@ -58,17 +58,6 @@ export async function getStat(options?: { [key: string]: any }) {
});
}
/** Get Subscription GET /v1/common/site/subscribe */
export async function getSubscription(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GetSubscriptionResponse }>(
'/v1/common/site/subscribe',
{
method: 'GET',
...(options || {}),
},
);
}
/** Get Tos Content GET /v1/common/site/tos */
export async function getTos(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GetTosResponse }>('/v1/common/site/tos', {

View File

@ -90,17 +90,6 @@ declare namespace API {
enabled: boolean;
};
type CheckoutOrderRequest = {
orderNo: string;
returnUrl?: string;
};
type CheckoutOrderResponse = {
type: string;
checkout_url?: string;
stripe?: StripePayment;
};
type CheckUserParams = {
email: string;
};
@ -175,7 +164,7 @@ declare namespace API {
};
type GetAvailablePaymentMethodsResponse = {
list: PaymentConfig[];
list: PaymenMethod[];
};
type GetGlobalConfigResponse = {
@ -196,14 +185,24 @@ declare namespace API {
protocol: string[];
};
type GetSubscriptionResponse = {
list: Subscribe[];
};
type GetTosResponse = {
tos_content: string;
};
type GetUserSubscribeTrafficLogsRequest = {
page: number;
size: number;
user_id: number;
subscribe_id: number;
start_time: number;
end_time: number;
};
type GetUserSubscribeTrafficLogsResponse = {
list: TrafficLog[];
total: number;
};
type GoogleLoginCallbackRequest = {
code: string;
state: string;
@ -329,6 +328,16 @@ declare namespace API {
updated_at: number;
};
type PaymenMethod = {
id: number;
name: string;
mark: string;
icon: string;
fee_mode: number;
fee_percent: number;
fee_amount: number;
};
type PaymentConfig = {
id: number;
name: string;
@ -417,6 +426,21 @@ declare namespace API {
list: OrderDetail[];
};
type QuerySubscribeGroupListResponse = {
list: SubscribeGroup[];
total: number;
};
type QuerySubscribeListResponse = {
list: Subscribe[];
total: number;
};
type QueryUserAffiliateListResponse = {
list: UserAffiliate[];
total: number;
};
type RechargeOrderRequest = {
amount: number;
payment: string;

View File

@ -4,7 +4,8 @@ import { Display } from '@/components/display';
import { SubscribeBilling } from '@/components/subscribe/billing';
import { SubscribeDetail } from '@/components/subscribe/detail';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, queryOrderDetail } from '@/services/user/order';
import { queryOrderDetail } from '@/services/user/order';
import { purchaseCheckout } from '@/services/user/portal';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
@ -50,7 +51,10 @@ export default function Page() {
enabled: !!orderNo && data?.status === 1,
queryKey: ['checkoutOrder', orderNo],
queryFn: async () => {
const { data } = await checkoutOrder({ orderNo: orderNo!, returnUrl: window.location.href });
const { data } = await purchaseCheckout({
orderNo: orderNo!,
returnUrl: window.location.href,
});
return data?.data;
},
});

View File

@ -1,6 +1,6 @@
import { GlobalMap } from '@/components/main/global-map';
import { Hero } from '@/components/main/hero';
import { ProductShowcase } from '@/components/main/product-showcase';
import { ProductShowcase } from '@/components/main/product-showcase/index';
import { Stats } from '@/components/main/stats';
export default function Home() {

View File

@ -0,0 +1,221 @@
'use client';
import { SubscribeBilling } from '@/components/subscribe/billing';
import CouponInput from '@/components/subscribe/coupon-input';
import { SubscribeDetail } from '@/components/subscribe/detail';
import DurationSelector from '@/components/subscribe/duration-selector';
import PaymentMethods from '@/components/subscribe/payment-methods';
import useGlobalStore from '@/config/use-global';
import { prePurchaseOrder, purchase } from '@/services/user/portal';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader } from '@workspace/ui/components/card';
import { Separator } from '@workspace/ui/components/separator';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { cn } from '@workspace/ui/lib/utils';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState, useTransition } from 'react';
export default function Content({ subscription }: { subscription?: API.Subscribe }) {
const t = useTranslations('subscribe');
const { common } = useGlobalStore();
const router = useRouter();
const [params, setParams] = useState<API.PortalPurchaseRequest>({
quantity: 1,
subscribe_id: 0,
payment: '',
coupon: '',
platform: 'email',
identifier: '',
password: '',
});
const [loading, startTransition] = useTransition();
const [isEmailValid, setIsEmailValid] = useState({
valid: false,
message: '',
});
const { data: order } = useQuery({
enabled: !!subscription?.id && !!params.payment,
queryKey: ['preCreateOrder', params.coupon, params.quantity, params.payment],
queryFn: async () => {
const { data } = await prePurchaseOrder({
...params,
subscribe_id: subscription?.id as number,
} as API.PrePurchaseOrderRequest);
return data.data;
},
});
useEffect(() => {
if (subscription) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscription?.id,
}));
}
}, [subscription]);
const handleChange = useCallback((field: keyof typeof params, value: string | number) => {
setParams((prev) => ({
...prev,
[field]: value,
}));
}, []);
const handleSubmit = useCallback(async () => {
startTransition(async () => {
try {
const { data } = await purchase(params);
console.log(data);
const { order_no, check_url, type } = data.data!;
if (order_no) {
if (type === 'link') {
window.location.href = check_url!;
}
router.push(`/purchasing/order?order_no=${order_no}`);
}
} catch (error) {
console.log(error);
}
});
}, [params, router, subscription?.id]);
if (!subscription) {
return <div className='p-6 text-center'>{t('subscriptionNotFound')}</div>;
}
return (
<div className='mx-auto mt-8 flex max-w-4xl flex-col gap-8 md:grid md:grid-cols-2 md:flex-row'>
<div className='flex flex-col gap-6'>
<Card>
<CardHeader> {common.site.site_name} </CardHeader>
<CardContent className='flex flex-col gap-2'>
<div className='flex flex-col gap-2'>
<EnhancedInput
className={cn({
'border-destructive': !isEmailValid.valid && params.identifier !== '',
})}
placeholder='Email'
type='email'
value={params.identifier || ''}
onValueChange={(value) => {
const email = value as string;
setParams((prev) => ({
...prev,
identifier: email,
}));
const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!reg.test(email)) {
setIsEmailValid({
valid: false,
message: '请输入有效的邮箱地址',
});
} else if (common.auth.email.enable_domain_suffix) {
const domain = email.split('@')[1];
const isValid = common.auth.email?.domain_suffix_list
.split('\n')
.includes(domain || '');
if (!isValid) {
setIsEmailValid({
valid: false,
message: '邮箱域名不在白名单中',
});
return;
}
} else {
setIsEmailValid({
valid: true,
message: '',
});
}
}}
required
/>
<p
className={cn('text-muted-foreground text-xs', {
'text-destructive': !isEmailValid.valid && params.identifier !== '',
})}
>
{isEmailValid.message || '请填写您的电子邮件地址。'}
</p>
</div>
{params.identifier && isEmailValid.valid && (
<div className='flex flex-col gap-2'>
<EnhancedInput
placeholder='Password'
type='password'
value={params.password || ''}
onValueChange={(value) => handleChange('password', value)}
/>
<p className='text-muted-foreground text-xs'>
</p>
</div>
)}
{/* <div>
<OAuthMethods />
</div> */}
</CardContent>
</Card>
<Card>
<CardContent className='grid gap-3 p-6 text-sm'>
<h2 className='text-xl font-semibold'>{subscription.name}</h2>
<p className='text-muted-foreground'>{subscription.description}</p>
<SubscribeDetail
subscribe={{
...subscription,
quantity: params.quantity,
}}
/>
<Separator />
<SubscribeBilling
order={{
...order,
quantity: params.quantity,
unit_price: subscription?.unit_price,
}}
/>
</CardContent>
</Card>
</div>
<div className='flex flex-col gap-6'>
<Card>
<CardContent className='p-6'>
<div className='grid gap-6'>
<DurationSelector
quantity={params.quantity!}
unitTime={subscription?.unit_time}
discounts={subscription?.discount}
onChange={(value) => handleChange('quantity', value)}
/>
<CouponInput
coupon={params.coupon}
onChange={(value) => handleChange('coupon', value)}
/>
<PaymentMethods
balance={false}
value={params.payment!}
onChange={(value) => handleChange('payment', value)}
/>
</div>
</CardContent>
</Card>
<Button
className='w-full'
size='lg'
disabled={!isEmailValid.valid || loading}
onClick={handleSubmit}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,266 @@
'use client';
import { Display } from '@/components/display';
import { SubscribeBilling } from '@/components/subscribe/billing';
import { SubscribeDetail } from '@/components/subscribe/detail';
import useGlobalStore from '@/config/use-global';
import { purchaseCheckout, queryPurchaseOrder } from '@/services/user/portal';
import { setAuthorization } from '@/utils/common';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@workspace/ui/components/card';
import { Separator } from '@workspace/ui/components/separator';
import { Icon } from '@workspace/ui/custom-components/icon';
import { formatDate } from '@workspace/ui/utils';
import { useCountDown } from 'ahooks';
import { addMinutes, format } from 'date-fns';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useEffect, useState } from 'react';
import StripePayment from './stripe';
export default function Page() {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [orderNo, setOrderNo] = useState<string>();
const [enabled, setEnabled] = useState<boolean>(false);
const { data } = useQuery({
enabled: enabled,
queryKey: ['queryPurchaseOrder', orderNo],
queryFn: async () => {
const { data } = await queryPurchaseOrder({ order_no: orderNo! });
if (data?.data?.status !== 1) {
setEnabled(false);
if (data?.data?.token) {
setAuthorization(data?.data?.token);
await new Promise((resolve) => setTimeout(resolve, 100));
await getUserInfo();
}
}
return data?.data;
},
refetchInterval: 3000,
});
const { data: payment } = useQuery({
enabled: !!orderNo && data?.status === 1,
queryKey: ['purchaseCheckout', orderNo],
queryFn: async () => {
const { data } = await purchaseCheckout({
orderNo: orderNo!,
returnUrl: window.location.href,
});
if (data.data?.type === 'url' && data.data?.checkout_url) {
window.location.href = data.data?.checkout_url;
}
return data?.data;
},
});
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('order_no')) {
setOrderNo(searchParams.get('order_no')!);
setEnabled(true);
}
}, []);
const [countDown, formattedRes] = useCountDown({
targetDate: data && format(addMinutes(data?.created_at, 15), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
});
const { hours, minutes, seconds } = formattedRes;
const countdownDisplay =
countDown > 0 ? (
<>
{hours.toString().length === 1 ? `0${hours}` : hours} :{' '}
{minutes.toString().length === 1 ? `0${minutes}` : minutes} :{' '}
{seconds.toString().length === 1 ? `0${seconds}` : seconds}
</>
) : (
<>{t('timeExpired')}</>
);
return (
<main className='container lg:mt-16'>
<div className='grid gap-4 xl:grid-cols-2'>
<Card className='order-2 xl:order-1'>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<div className='grid gap-0.5'>
<CardTitle className='flex flex-col text-lg'>
{t('orderNumber')}
<span>{data?.order_no}</span>
</CardTitle>
<CardDescription>
{t('createdAt')}: {formatDate(data?.created_at)}
</CardDescription>
</div>
</CardHeader>
<CardContent className='grid gap-3 p-6 text-sm'>
<div className='font-semibold'>{t('paymentMethod')}</div>
<dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>
{data?.payment && <Badge>{t(`methods.${data?.payment}`)}</Badge>}
</dt>
</div>
</dl>
<Separator />
{data?.status && [1, 2].includes(data.status) && (
<SubscribeDetail
subscribe={{
...data?.subscribe,
quantity: data?.quantity,
}}
/>
)}
{data?.status === 3 && (
<>
<div className='font-semibold'>{t('resetTraffic')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'>
{t('resetPrice')}
</span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
{data?.status === 4 && (
<>
<div className='font-semibold'>{t('balanceRecharge')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'>
{t('rechargeAmount')}
</span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
<Separator />
<SubscribeBilling
order={{
...data,
unit_price: data?.subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<Card className='order-1 flex flex-auto items-center justify-center xl:order-2'>
<CardContent className='py-16'>
{data?.status && [2, 5].includes(data?.status) && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('paymentSuccess')}</h3>
<Icon icon='mdi:success-circle-outline' className='text-7xl text-green-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/dashboard'>{t('subscribeNow')}</Link>
</Button>
<Button variant='outline'>
<Link href='/document'>{t('viewDocument')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'url' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('waitingForPayment')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<Icon icon='mdi:access-time' className='text-muted-foreground text-7xl' />
<div className='flex gap-4'>
<Button
onClick={() => {
if (payment?.checkout_url) {
window.location.href = payment?.checkout_url;
}
}}
>
{t('goToPayment')}
</Button>
<Button variant='outline'>
<Link href='/'>{t('productList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'qr' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<QRCodeCanvas
value={payment?.checkout_url || ''}
size={208}
imageSettings={{
src: `/payment/alipay_f2f.svg`,
width: 24,
height: 24,
excavate: true,
}}
/>
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'stripe' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
{payment.stripe && <StripePayment {...payment.stripe} />}
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status && [3, 4].includes(data?.status) && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('orderClosed')}</h3>
<Icon icon='mdi:cancel' className='text-7xl text-red-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</main>
);
}

View File

@ -0,0 +1,109 @@
import { Elements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js';
import { QRCodeCanvas } from 'qrcode.react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
interface StripePaymentProps {
method: string;
client_secret: string;
publishable_key: string;
}
const StripePayment: React.FC<StripePaymentProps> = ({
method,
client_secret,
publishable_key,
}) => {
const stripePromise = useMemo(() => loadStripe(publishable_key), [publishable_key]);
return (
<Elements stripe={stripePromise}>
<CheckoutForm method={method} client_secret={client_secret} />
</Elements>
);
};
const CheckoutForm: React.FC<Omit<StripePaymentProps, 'publishable_key'>> = ({
client_secret,
method,
}) => {
const stripe = useStripe();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleError = useCallback((message: string) => {
setErrorMessage(message);
setIsSubmitted(false);
}, []);
const confirmPayment = useCallback(async (): Promise<PaymentIntentResult | null> => {
if (!stripe) {
handleError('Stripe.js is not loaded.');
return null;
}
if (method === 'alipay') {
return await stripe.confirmAlipayPayment(
client_secret,
{ return_url: window.location.href },
{ handleActions: false },
);
}
return await stripe.confirmWechatPayPayment(
client_secret,
{
payment_method_options: { wechat_pay: { client: 'web' } },
},
{ handleActions: false },
);
}, [client_secret, method, stripe, handleError]);
const autoSubmit = useCallback(async () => {
if (isSubmitted) return;
setIsSubmitted(true);
try {
const result = await confirmPayment();
if (!result) return;
const { error, paymentIntent } = result;
if (error) return handleError(error.message!);
if (paymentIntent?.status === 'requires_action') {
const nextAction = paymentIntent.next_action as any;
const qrUrl =
method === 'alipay'
? nextAction?.alipay_handle_redirect?.url
: nextAction?.wechat_pay_display_qr_code?.image_url_svg;
setQrCodeUrl(qrUrl || null);
}
} catch (error) {
handleError('An unexpected error occurred');
}
}, [confirmPayment, isSubmitted, handleError, method]);
useEffect(() => {
autoSubmit();
}, [autoSubmit]);
return qrCodeUrl ? (
<QRCodeCanvas
value={qrCodeUrl}
size={208}
imageSettings={{
src: `/payment/${method}.svg`,
width: 24,
height: 24,
excavate: true,
}}
/>
) : (
errorMessage
);
};
export default StripePayment;

View File

@ -0,0 +1,23 @@
import { getSubscription } from '@/services/user/portal';
import Content from './content';
export default async function Page({
searchParams,
}: {
searchParams: Promise<{
id: string;
}>;
}) {
const { id } = await searchParams;
const { data } = await getSubscription({
skipErrorHandler: true,
});
const subscriptionList = data.data?.list || [];
const subscription = subscriptionList.find((item) => item.id === Number(id));
return (
<main className='container space-y-16'>
<Content subscription={subscription} />
</main>
);
}

View File

@ -1,13 +1,11 @@
'use client';
import { OAuthMethods } from '@/components/auth/oauth-methods';
import LanguageSwitch from '@/components/language-switch';
import ThemeSwitch from '@/components/theme-switch';
import useGlobalStore from '@/config/use-global';
import { oAuthLogin } from '@/services/common/oauth';
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
import { Button } from '@workspace/ui/components/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Icon } from '@workspace/ui/custom-components/icon';
import LoginLottie from '@workspace/ui/lotties/login.json';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
@ -15,18 +13,10 @@ import Link from 'next/link';
import EmailAuthForm from './email/auth-form';
import PhoneAuthForm from './phone/auth-form';
const icons = {
apple: 'uil:apple',
google: 'logos:google-icon',
facebook: 'logos:facebook',
github: 'uil:github',
telegram: 'logos:telegram',
};
export default function Page() {
const t = useTranslations('auth');
const { common } = useGlobalStore();
const { site, auth, oauth_methods } = common;
const { site, auth } = common;
const AUTH_METHODS = [
{
@ -41,10 +31,6 @@ export default function Page() {
},
].filter((method) => method.enabled);
const OAUTH_METHODS = oauth_methods?.filter(
(method) => !['mobile', 'email', 'device'].includes(method),
);
return (
<main className='bg-muted/50 flex h-full min-h-screen items-center'>
<div className='flex size-full flex-auto flex-col lg:flex-row'>
@ -95,38 +81,7 @@ export default function Page() {
)}
</div>
<div className='py-8'>
{OAUTH_METHODS?.length > 0 && (
<>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div>
<div className='mt-6 flex justify-center gap-4 *:size-12 *:p-2'>
{OAUTH_METHODS?.map((method: any) => {
return (
<Button
key={method}
variant='ghost'
size='icon'
asChild
onClick={async () => {
const { data } = await oAuthLogin({
method,
redirect: `${window.location.origin}/oauth/${method}`,
});
if (data.data?.redirect) {
window.location.href = data.data?.redirect;
}
}}
>
<Icon icon={icons[method as keyof typeof icons]} />
</Button>
);
})}
</div>
</>
)}
<OAuthMethods />
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-5'>

View File

@ -0,0 +1,56 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { oAuthLogin } from '@/services/common/oauth';
import { Button } from '@workspace/ui/components/button';
import { Icon } from '@workspace/ui/custom-components/icon';
const icons = {
apple: 'uil:apple',
google: 'logos:google-icon',
facebook: 'logos:facebook',
github: 'uil:github',
telegram: 'logos:telegram',
};
export function OAuthMethods() {
const { common } = useGlobalStore();
const { oauth_methods } = common;
const OAUTH_METHODS = oauth_methods?.filter(
(method) => !['mobile', 'email', 'device'].includes(method),
);
return (
OAUTH_METHODS?.length > 0 && (
<>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or continue with
</span>
</div>
<div className='mt-6 flex justify-center gap-4 *:size-12 *:p-2'>
{OAUTH_METHODS?.map((method: any) => {
return (
<Button
key={method}
variant='ghost'
size='icon'
asChild
onClick={async () => {
const { data } = await oAuthLogin({
method,
redirect: `${window.location.origin}/oauth/${method}`,
});
if (data.data?.redirect) {
window.location.href = data.data?.redirect;
}
}}
>
<Icon icon={icons[method as keyof typeof icons]} />
</Button>
);
})}
</div>
</>
)
);
}

View File

@ -2,8 +2,7 @@
import { Display } from '@/components/display';
import { SubscribeDetail } from '@/components/subscribe/detail';
import { getSubscription } from '@/services/common/common';
import { useQuery } from '@tanstack/react-query';
import useGlobalStore from '@/config/use-global';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardFooter, CardHeader } from '@workspace/ui/components/card';
import { Separator } from '@workspace/ui/components/separator';
@ -14,19 +13,15 @@ import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { Key, ReactNode } from 'react';
export function ProductShowcase() {
interface ProductShowcaseProps {
subscriptionData: API.Subscribe[];
}
export function Content({ subscriptionData }: ProductShowcaseProps) {
const t = useTranslations('index');
const { data } = useQuery({
queryKey: ['getSubscription'],
queryFn: async () => {
const { data } = await getSubscription({
skipErrorHandler: true,
});
return data.data?.list || [];
},
});
if (data?.length === 0) return null;
const { user } = useGlobalStore();
return (
<motion.section
initial={{ opacity: 0 }}
@ -51,7 +46,7 @@ export function ProductShowcase() {
{t('product_showcase_description')}
</motion.p>
<div className='mx-auto flex flex-wrap justify-center gap-8 overflow-x-auto overflow-y-hidden *:max-w-80 *:flex-auto'>
{data?.map((item, index) => (
{subscriptionData?.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 50 }}
@ -132,7 +127,9 @@ export function ProductShowcase() {
className='absolute bottom-0 left-0 w-full rounded-b-xl rounded-t-none'
asChild
>
<Link href='/subscribe'>{t('subscribe')}</Link>
<Link href={user ? '/subscribe' : `/purchasing?id=${item.id}`}>
{t('subscribe')}
</Link>
</Button>
</motion.div>
</CardFooter>

View File

@ -0,0 +1,17 @@
import { getSubscription } from '@/services/user/portal';
import { Content } from './content';
export async function ProductShowcase() {
try {
const { data } = await getSubscription({
skipErrorHandler: true,
});
const subscriptionList = data.data?.list || [];
if (subscriptionList.length === 0) return null;
return <Content subscriptionData={subscriptionList} />;
} catch (error) {
return null;
}
}

View File

@ -1,6 +1,8 @@
'use client';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { getAvailablePaymentMethods } from '@/services/user/portal';
// import { getAvailablePaymentMethods } from '@/services/user/payment';
// import { getAvailablePaymentMethods } from '@/services/user/payment';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group';
@ -11,22 +13,30 @@ import React, { memo } from 'react';
interface PaymentMethodsProps {
value: string;
onChange: (value: string) => void;
balance?: boolean;
}
const PaymentMethods: React.FC<PaymentMethodsProps> = ({ value, onChange }) => {
const PaymentMethods: React.FC<PaymentMethodsProps> = ({ value, onChange, balance = true }) => {
const t = useTranslations('subscribe');
const { data } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryKey: ['getAvailablePaymentMethods', { balance }],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
const methods = data.data?.list || [];
if (!value && methods[0]?.mark) onChange(methods[0]?.mark);
if (balance) return methods;
return methods.filter((item) => item.mark !== 'balance');
},
});
return (
<>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup className='grid grid-cols-5 gap-2' value={value} onValueChange={onChange}>
<RadioGroup
className='grid grid-cols-2 gap-2 md:grid-cols-5'
value={value}
onValueChange={onChange}
>
{data?.map((item) => (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />

View File

@ -4,7 +4,8 @@ import CouponInput from '@/components/subscribe/coupon-input';
import DurationSelector from '@/components/subscribe/duration-selector';
import PaymentMethods from '@/components/subscribe/payment-methods';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, preCreateOrder, purchase } from '@/services/user/order';
import { preCreateOrder, purchase } from '@/services/user/order';
import { purchaseCheckout } from '@/services/user/portal';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent } from '@workspace/ui/components/card';
@ -69,7 +70,7 @@ export default function Purchase({ subscribe, setSubscribe }: Readonly<PurchaseP
const response = await purchase(params as API.PurchaseOrderRequest);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
const { data } = await purchaseCheckout({
orderNo,
returnUrl: `${window.location.origin}/payment?order_no=${orderNo}`,
});

View File

@ -1,8 +1,9 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, recharge } from '@/services/user/order';
import { recharge } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { purchaseCheckout } from '@/services/user/portal';
import { useQuery } from '@tanstack/react-query';
import { Button, ButtonProps } from '@workspace/ui/components/button';
import {
@ -133,7 +134,7 @@ export default function Recharge(props: Readonly<ButtonProps>) {
const response = await recharge(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
const { data } = await purchaseCheckout({
orderNo,
returnUrl: `${window.location.origin}/payment?order_no=${orderNo}`,
});

View File

@ -4,7 +4,8 @@ import CouponInput from '@/components/subscribe/coupon-input';
import DurationSelector from '@/components/subscribe/duration-selector';
import PaymentMethods from '@/components/subscribe/payment-methods';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, preCreateOrder, renewal } from '@/services/user/order';
import { preCreateOrder, renewal } from '@/services/user/order';
import { purchaseCheckout } from '@/services/user/portal';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent } from '@workspace/ui/components/card';
@ -77,7 +78,7 @@ export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
const response = await renewal(params as API.RenewalOrderRequest);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
const { data } = await purchaseCheckout({
orderNo,
returnUrl: `${window.location.origin}/payment?order_no=${orderNo}`,
});

View File

@ -2,7 +2,8 @@
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, resetTraffic } from '@/services/user/order';
import { resetTraffic } from '@/services/user/order';
import { purchaseCheckout } from '@/services/user/portal';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
@ -84,7 +85,7 @@ export default function ResetTraffic({ id, replacement }: Readonly<ResetTrafficP
const response = await resetTraffic(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
const { data } = await purchaseCheckout({
orderNo,
returnUrl: `${window.location.origin}/payment?order_no=${orderNo}`,
});

View File

@ -58,17 +58,6 @@ export async function getStat(options?: { [key: string]: any }) {
});
}
/** Get Subscription GET /v1/common/site/subscribe */
export async function getSubscription(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GetSubscriptionResponse }>(
'/v1/common/site/subscribe',
{
method: 'GET',
...(options || {}),
},
);
}
/** Get Tos Content GET /v1/common/site/tos */
export async function getTos(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GetTosResponse }>('/v1/common/site/tos', {

View File

@ -90,17 +90,6 @@ declare namespace API {
enabled: boolean;
};
type CheckoutOrderRequest = {
orderNo: string;
returnUrl?: string;
};
type CheckoutOrderResponse = {
type: string;
checkout_url?: string;
stripe?: StripePayment;
};
type CheckUserParams = {
email: string;
};
@ -175,7 +164,7 @@ declare namespace API {
};
type GetAvailablePaymentMethodsResponse = {
list: PaymentConfig[];
list: PaymenMethod[];
};
type GetGlobalConfigResponse = {
@ -196,14 +185,24 @@ declare namespace API {
protocol: string[];
};
type GetSubscriptionResponse = {
list: Subscribe[];
};
type GetTosResponse = {
tos_content: string;
};
type GetUserSubscribeTrafficLogsRequest = {
page: number;
size: number;
user_id: number;
subscribe_id: number;
start_time: number;
end_time: number;
};
type GetUserSubscribeTrafficLogsResponse = {
list: TrafficLog[];
total: number;
};
type GoogleLoginCallbackRequest = {
code: string;
state: string;
@ -329,6 +328,16 @@ declare namespace API {
updated_at: number;
};
type PaymenMethod = {
id: number;
name: string;
mark: string;
icon: string;
fee_mode: number;
fee_percent: number;
fee_amount: number;
};
type PaymentConfig = {
id: number;
name: string;
@ -417,6 +426,21 @@ declare namespace API {
list: OrderDetail[];
};
type QuerySubscribeGroupListResponse = {
list: SubscribeGroup[];
total: number;
};
type QuerySubscribeListResponse = {
list: Subscribe[];
total: number;
};
type QueryUserAffiliateListResponse = {
list: UserAffiliate[];
total: number;
};
type RechargeOrderRequest = {
amount: number;
payment: string;

View File

@ -1,11 +1,12 @@
// @ts-ignore
/* eslint-disable */
// API 更新时间:
// API 唯一标识:
import * as announcement from './announcement';
import * as document from './document';
import * as order from './order';
import * as payment from './payment';
import * as portal from './portal';
import * as subscribe from './subscribe';
import * as ticket from './ticket';
import * as user from './user';
@ -14,6 +15,7 @@ export default {
document,
order,
payment,
portal,
subscribe,
ticket,
user,

View File

@ -2,21 +2,6 @@
/* eslint-disable */
import request from '@/utils/request';
/** Checkout order POST /v1/public/order/checkout */
export async function checkoutOrder(
body: API.CheckoutOrderRequest,
options?: { [key: string]: any },
) {
return request<API.Response & { data?: API.CheckoutOrderResponse }>('/v1/public/order/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Close order POST /v1/public/order/close */
export async function closeOrder(body: API.CloseOrderRequest, options?: { [key: string]: any }) {
return request<API.Response & { data?: any }>('/v1/public/order/close', {

View File

@ -0,0 +1,91 @@
// @ts-ignore
/* eslint-disable */
import request from '@/utils/request';
/** Purchase Checkout POST /v1/public/portal/order/checkout */
export async function purchaseCheckout(
body: API.CheckoutOrderRequest,
options?: { [key: string]: any },
) {
return request<API.Response & { data?: API.CheckoutOrderResponse }>(
'/v1/public/portal/order/checkout',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
},
);
}
/** Query Purchase Order GET /v1/public/portal/order/status */
export async function queryPurchaseOrder(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.QueryPurchaseOrderParams,
options?: { [key: string]: any },
) {
return request<API.Response & { data?: API.QueryPurchaseOrderResponse }>(
'/v1/public/portal/order/status',
{
method: 'GET',
params: {
...params,
},
...(options || {}),
},
);
}
/** Get available payment methods GET /v1/public/portal/payment-method */
export async function getAvailablePaymentMethods(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GetAvailablePaymentMethodsResponse }>(
'/v1/public/portal/payment-method',
{
method: 'GET',
...(options || {}),
},
);
}
/** Pre Purchase Order POST /v1/public/portal/pre */
export async function prePurchaseOrder(
body: API.PrePurchaseOrderRequest,
options?: { [key: string]: any },
) {
return request<API.Response & { data?: API.PrePurchaseOrderResponse }>('/v1/public/portal/pre', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** Purchase subscription POST /v1/public/portal/purchase */
export async function purchase(body: API.PortalPurchaseRequest, options?: { [key: string]: any }) {
return request<API.Response & { data?: API.PortalPurchaseResponse }>(
'/v1/public/portal/purchase',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
},
);
}
/** Get Subscription GET /v1/public/portal/subscribe */
export async function getSubscription(options?: { [key: string]: any }) {
return request<API.Response & { data?: API.GetSubscriptionResponse }>(
'/v1/public/portal/subscribe',
{
method: 'GET',
...(options || {}),
},
);
}

View File

@ -187,7 +187,7 @@ declare namespace API {
};
type GetAvailablePaymentMethodsResponse = {
list: PaymentConfig[];
list: PaymenMethod[];
};
type GetLoginLogParams = {
@ -224,6 +224,24 @@ declare namespace API {
total: number;
};
type GetSubscriptionResponse = {
list: Subscribe[];
};
type GetUserSubscribeTrafficLogsRequest = {
page: number;
size: number;
user_id: number;
subscribe_id: number;
start_time: number;
end_time: number;
};
type GetUserSubscribeTrafficLogsResponse = {
list: TrafficLog[];
total: number;
};
type GetUserTicketDetailRequest = {
id: number;
};
@ -351,6 +369,16 @@ declare namespace API {
updated_at: number;
};
type PaymenMethod = {
id: number;
name: string;
mark: string;
icon: string;
fee_mode: number;
fee_percent: number;
fee_amount: number;
};
type PaymentConfig = {
id: number;
name: string;
@ -364,6 +392,21 @@ declare namespace API {
enable: boolean;
};
type PortalPurchaseRequest = {
identifier: string;
platform: string;
password?: string;
payment: string;
subscribe_id: number;
quantity: number;
coupon?: string;
turnstile_token?: string;
};
type PortalPurchaseResponse = {
order_no: string;
};
type PreOrderResponse = {
price: number;
amount: number;
@ -374,6 +417,22 @@ declare namespace API {
fee_amount: number;
};
type PrePurchaseOrderRequest = {
payment: string;
subscribe_id: number;
quantity: number;
coupon?: string;
};
type PrePurchaseOrderResponse = {
price: number;
amount: number;
discount: number;
coupon: string;
coupon_discount: number;
fee_amount: number;
};
type PreRenewalOrderResponse = {
orderNo: string;
};
@ -467,6 +526,30 @@ declare namespace API {
list: OrderDetail[];
};
type QueryPurchaseOrderParams = {
order_no: string;
};
type QueryPurchaseOrderRequest = {
order_no: string;
};
type QueryPurchaseOrderResponse = {
order_no: string;
subscribe: Subscribe;
quantity: number;
price: number;
amount: number;
discount: number;
coupon: string;
coupon_discount: number;
fee_amount: number;
payment: string;
status: number;
created_at: number;
token?: string;
};
type QuerySubscribeGroupListResponse = {
list: SubscribeGroup[];
total: number;

View File

@ -52,6 +52,7 @@ export function Logout() {
const pathname = location.pathname;
if (
!['', '/', '/auth', '/tos', '/privacy-policy'].includes(pathname) &&
!pathname.startsWith('/purchasing') &&
!pathname.startsWith('/oauth/')
) {
setRedirectUrl(location.pathname);