392 lines
13 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.

// 加载状态组件
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;