392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
// 加载状态组件
|
||
import { unitConversion } from '@workspace/airo-ui/utils';
|
||
import { useMemo, useRef } from 'react';
|
||
|
||
const LoadingState = () => {
|
||
const t = useTranslations('components.offerDialog');
|
||
return (
|
||
<div className='py-12 text-center'>
|
||
<div className='mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-[#0F2C53]'></div>
|
||
<p className='mt-4 text-gray-600'>{t('loading')}</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 错误状态组件
|
||
const ErrorState = ({ onRetry }: { onRetry: () => void }) => {
|
||
const t = useTranslations('components.offerDialog');
|
||
return (
|
||
<div className='py-12 text-center'>
|
||
<p className='text-lg text-red-500'>{t('loadFailed')}</p>
|
||
<button
|
||
onClick={onRetry}
|
||
className='mt-4 rounded-lg bg-[#0F2C53] px-6 py-2 text-white transition-colors hover:bg-[#0A2C47]'
|
||
>
|
||
{t('reload')}
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 空状态组件
|
||
const EmptyState = ({ message }: { message: string }) => (
|
||
<div className='py-12 text-center'>
|
||
<p className='text-lg text-gray-500'>{message}</p>
|
||
</div>
|
||
);
|
||
|
||
import { useLocale } from 'next-intl';
|
||
|
||
interface PriceDisplayProps {
|
||
plan: PlanProps;
|
||
}
|
||
interface PlanProps extends API.Subscribe {
|
||
origin_price: string;
|
||
discount_price: string;
|
||
}
|
||
// 价格显示组件
|
||
const PriceDisplay: React.FC<PriceDisplayProps> = ({ plan }) => {
|
||
const t = useTranslations('components.offerDialog');
|
||
const locale = useLocale(); // 获取当前语言环境
|
||
const { common } = useGlobalStore();
|
||
const discountItem = plan.discount.find((v) => v.quantity === 12) ?? { discount: 0 };
|
||
const discount =
|
||
locale === 'zh-CN' ? discountItem?.discount / 10 : `${100 - discountItem?.discount}%`;
|
||
return (
|
||
<div className='mb-2 sm:mb-4'>
|
||
<div className='mb-1 flex items-baseline gap-2'>
|
||
{plan.origin_price && (
|
||
<span className='text-2xl font-bold leading-[1.125em] text-[#666666] line-through'>
|
||
{common?.currency?.currency_symbol}
|
||
{plan.origin_price}
|
||
</span>
|
||
)}
|
||
<span className='text-2xl font-bold leading-[1.125em] text-[#091B33]'>
|
||
{common?.currency?.currency_symbol}
|
||
{plan.discount_price}
|
||
</span>
|
||
<span className='text-sm font-normal text-[#4D4D4D] sm:text-base'>{t('perYear')}</span>
|
||
</div>
|
||
<div className={'h-[15px]'}>
|
||
{plan.origin_price && (
|
||
<p className='text-left text-[10px] font-normal text-black'>
|
||
{t('yearlyDiscount', { discount })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
import { useLoginDialog } from '@/app/auth/LoginDialogContext';
|
||
import { Display } from '@/components/display';
|
||
import Modal, { AlertDialogRef } from '@/components/Modal';
|
||
import Purchase from '@/components/subscribe/purchase';
|
||
import useGlobalStore from '@/config/use-global';
|
||
import { queryUserSubscribe } from '@/services/user/user';
|
||
import { AiroButton } from '@workspace/airo-ui/components/AiroButton';
|
||
import { useTranslations } from 'next-intl';
|
||
import React from 'react';
|
||
|
||
// 星级评分组件
|
||
const StarRating = ({ rating, maxRating = 5 }: { rating: number; maxRating?: number }) => (
|
||
<div className='text-right text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
{Array.from({ length: Math.min(rating, maxRating) }, (_, i) => (
|
||
<span key={i} className='text-black'>
|
||
✭
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
import SvgIcon from '@/components/SvgIcon';
|
||
// 功能列表组件
|
||
const FeatureList = ({ plan }: { plan: API.Subscribe }) => {
|
||
const t = useTranslations('subscribe.detail');
|
||
const tSubscribe = useTranslations('subscribe');
|
||
const tOffer = useTranslations('components.offerDialog');
|
||
const features = [
|
||
{ label: tOffer('availableNodes'), icon: 'feature/Vector (5)', value: plan?.server_count },
|
||
];
|
||
return (
|
||
<div className='mt-6 space-y-0 sm:mt-6'>
|
||
<ul className='space-y-0'>
|
||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={'feature/Vector'} className={'ml-1 mr-2'} />
|
||
{t('availableTraffic')}
|
||
</span>
|
||
<span>
|
||
<Display type='traffic' value={plan?.traffic} unlimited />
|
||
</span>
|
||
</div>
|
||
</li>
|
||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={'feature/Vector (1)'} className={'ml-1 mr-2'} />
|
||
{tSubscribe('billing.duration')}
|
||
</span>
|
||
<span>
|
||
{plan.origin_price ? '365' : '30'}
|
||
{tSubscribe('Day')}
|
||
</span>
|
||
</div>
|
||
</li>
|
||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={'feature/Vector (4)'} className={'ml-1 mr-2'} />
|
||
{t('connectionSpeed')}
|
||
</span>
|
||
<span>
|
||
<Display type='trafficSpeed' value={plan?.speed_limit} unlimited />
|
||
</span>
|
||
</div>
|
||
</li>
|
||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={'feature/Group 69'} className={'ml-1 mr-2'} />
|
||
{plan?.name?.includes('Pro') ? (
|
||
<span className={'font-medium'}>{t('IEPL_Pro')}</span>
|
||
) : (
|
||
<span>{t('General_Line')}</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</li>
|
||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={'feature/Vector (3)'} className={'ml-1 mr-2'} />
|
||
{t('connectedDevices')}
|
||
</span>
|
||
<span>
|
||
<Display value={plan?.device_limit} type='number' unlimited />
|
||
</span>
|
||
</div>
|
||
</li>
|
||
{features.map((feature) => (
|
||
<li
|
||
key={feature.label}
|
||
className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'
|
||
>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={feature.icon} className={'ml-1 mr-2'} />
|
||
{feature.label}:
|
||
</span>
|
||
<span>{feature.value}</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
<li className='py-1 text-xs font-light leading-[1.8461538461538463em] text-black sm:text-sm'>
|
||
<div className={'flex items-start justify-between'}>
|
||
<span className='flex items-center'>
|
||
<SvgIcon name={'feature/Group 68'} className={'ml-1 mr-2'} />
|
||
{tOffer('networkStabilityIndex')}
|
||
</span>
|
||
<StarRating rating={5} />
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 套餐卡片组件
|
||
const PlanCard: React.FC<{
|
||
plan: PlanProps;
|
||
tabValue: string;
|
||
onSubscribe?: (plan: API.Subscribe) => void;
|
||
isFirstCard?: boolean;
|
||
}> = ({ plan, onSubscribe }) => {
|
||
const { user } = useGlobalStore();
|
||
const { openLoginDialog } = useLoginDialog();
|
||
const t = useTranslations('components.offerDialog');
|
||
const ModalRef = useRef<AlertDialogRef>(null);
|
||
async function handleSubscribe() {
|
||
if (!user) {
|
||
// 强制登陆
|
||
openLoginDialog(false);
|
||
return;
|
||
}
|
||
|
||
// 有生效套餐进行弹窗提示
|
||
const { data } = await queryUserSubscribe();
|
||
if (data?.data?.list?.[0]?.status === 1) {
|
||
ModalRef.current?.show();
|
||
return;
|
||
}
|
||
|
||
onSubscribe?.(plan);
|
||
}
|
||
|
||
return (
|
||
<div className='relative w-full min-w-[300px] cursor-pointer rounded-[20px] border border-[#D9D9D9] bg-white p-8 shadow-[0_0_52.6px_1px_rgba(15,44,83,0.05)] transition-all duration-300 sm:w-[345px]'>
|
||
<div className={'ml-4'}>
|
||
{/* 套餐名称 */}
|
||
<h3 className='mb-3 text-left text-xl font-normal sm:text-base'>{plan.name}</h3>
|
||
|
||
{/* 价格区域 */}
|
||
<PriceDisplay plan={plan} />
|
||
</div>
|
||
|
||
{/* 订阅按钮 */}
|
||
<AiroButton onClick={handleSubscribe} className='h-10 w-full text-sm font-medium'>
|
||
{t('subscribe')}
|
||
</AiroButton>
|
||
|
||
<div className={'mr-4'}>
|
||
{/* 功能列表 */}
|
||
<FeatureList plan={plan} />
|
||
</div>
|
||
|
||
<Modal
|
||
ref={ModalRef}
|
||
title={'【重要提示】'}
|
||
description={
|
||
'您已有正在生效的套餐,如继续购买新的套餐,原套餐将自动失效。账户套餐将自动转为最新套餐。未使用部分不支持退款或叠加。'
|
||
}
|
||
confirmText={'同意'}
|
||
cancelText={'取消'}
|
||
onConfirm={() => onSubscribe?.(plan)}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
PlanCard.displayName = 'PlanCard';
|
||
|
||
// 套餐列表组件
|
||
export const PlanList = ({
|
||
plans,
|
||
tabValue,
|
||
isLoading,
|
||
error,
|
||
onRetry,
|
||
emptyMessage,
|
||
onSubscribe,
|
||
}: {
|
||
plans: PlanProps[];
|
||
tabValue: string;
|
||
isLoading: boolean;
|
||
error: any;
|
||
onRetry: () => void;
|
||
emptyMessage: string;
|
||
onSubscribe?: (plan: API.Subscribe) => void;
|
||
}) => {
|
||
if (isLoading) return <LoadingState />;
|
||
if (error) return <ErrorState onRetry={onRetry} />;
|
||
if (plans.length === 0) return <EmptyState message={emptyMessage} />;
|
||
|
||
return (
|
||
<div className={'flex w-full justify-center px-[6px] sm:px-0'}>
|
||
<div className='flex w-full flex-col flex-wrap gap-6 sm:w-auto md:flex-row'>
|
||
{plans.map((plan, index) => (
|
||
<PlanCard
|
||
key={`${plan.id}-${plan.name}`}
|
||
tabValue={tabValue}
|
||
plan={plan}
|
||
onSubscribe={onSubscribe}
|
||
isFirstCard={index === 0} // 标识第一项
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface TabContentProps {
|
||
tabValue: string;
|
||
subscribeData: API.Subscribe[];
|
||
isLoading: boolean;
|
||
error: Error | null;
|
||
onRetry: () => void;
|
||
firstPlanCardRef?: React.RefObject<HTMLDivElement | null>;
|
||
}
|
||
|
||
const TabContent: React.FC<TabContentProps> = ({
|
||
tabValue,
|
||
subscribeData,
|
||
isLoading,
|
||
error,
|
||
onRetry,
|
||
}) => {
|
||
const t = useTranslations('components.offerDialog');
|
||
|
||
// 处理套餐数据的工具函数
|
||
const processPlanData = (item: API.Subscribe, isYearly: boolean): PlanProps => {
|
||
if (isYearly) {
|
||
const discountItem = item.discount?.find((v) => v.quantity === 12);
|
||
return {
|
||
...item,
|
||
origin_price: unitConversion('centsToDollars', item.unit_price).toString(), // 原价
|
||
discount_price: unitConversion(
|
||
'centsToDollars',
|
||
item.unit_price * ((discountItem?.discount || 100) / 100),
|
||
).toString(), // 优惠价格
|
||
};
|
||
} else {
|
||
return {
|
||
...item,
|
||
origin_price: '', // 月付没有原价
|
||
discount_price: unitConversion('centsToDollars', item.unit_price).toString(), // 月付价格
|
||
};
|
||
}
|
||
};
|
||
|
||
// 使用 useMemo 优化数据处理性能
|
||
const yearlyPlans: PlanProps[] = useMemo(
|
||
() => subscribeData?.map((item) => processPlanData(item, true)),
|
||
[subscribeData],
|
||
);
|
||
|
||
const monthlyPlans: PlanProps[] = useMemo(
|
||
() => subscribeData?.map((item) => processPlanData(item, false)),
|
||
[subscribeData],
|
||
);
|
||
|
||
const PurchaseRef = useRef<{
|
||
show: (subscribe: API.Subscribe, tabValue: string) => void;
|
||
hide: () => void;
|
||
}>(null);
|
||
// 处理订阅点击
|
||
const handleSubscribe = (plan: API.Subscribe) => {
|
||
// 这里可以添加订阅逻辑,比如跳转到支付页面或显示确认对话框
|
||
PurchaseRef.current?.show(plan, tabValue);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
{tabValue === 'year' && (
|
||
<PlanList
|
||
plans={yearlyPlans}
|
||
isLoading={isLoading}
|
||
tabValue={tabValue}
|
||
error={error}
|
||
onRetry={onRetry}
|
||
emptyMessage={t('noYearlyPlan')}
|
||
onSubscribe={handleSubscribe}
|
||
/>
|
||
)}
|
||
{tabValue === 'month' && (
|
||
<PlanList
|
||
plans={monthlyPlans}
|
||
tabValue={tabValue}
|
||
isLoading={isLoading}
|
||
error={error}
|
||
onRetry={onRetry}
|
||
emptyMessage={t('noMonthlyPlan')}
|
||
onSubscribe={handleSubscribe}
|
||
/>
|
||
)}
|
||
<Purchase ref={PurchaseRef} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TabContent;
|