Browse Source

feat: 购买页面

BaiLuoYan 1 tháng trước cách đây
mục cha
commit
e626006d4d

+ 7 - 3
.vscode/settings.json

@@ -7,15 +7,19 @@
         "watchDirectory": "useFsEvents",
         "fallbackPolling": "dynamicPriority",
         "synchronousWatchDirectory": true,
-        "excludeDirectories": ["**/node_modules", "**/dist", "**/.git"]
+        "excludeDirectories": [
+            "**/node_modules",
+            "**/dist",
+            "**/.git"
+        ]
     },
     "typescript.tsserver.experimental.enableProjectDiagnostics": true,
     "i18n-ally.localesPaths": [
         "src/locales"
     ],
     "i18n-ally.keystyle": "nested",
-    "i18n-ally.sourceLanguage": "en-US",
-    "i18n-ally.displayLanguage": "en-US",
+    "i18n-ally.sourceLanguage": "zh-CN",
+    "i18n-ally.displayLanguage": "zh-CN",
     "i18n-ally.sortKeys": true,
     "i18n-ally.pathMatcher": "{locale}/{namespace}.ts",
     "i18n-ally.namespace": true,

+ 2 - 0
src/App.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { ConfigProvider } from 'antd';
 import enUS from 'antd/locale/en_US';
 import faIR from 'antd/locale/fa_IR';
+import zhCN from 'antd/locale/zh_CN';
 import { useTranslation } from 'react-i18next';
 import { RouterProvider } from 'react-router-dom';
 
@@ -13,6 +14,7 @@ import models from './utils/model/autoImportModels';
 const localeMap = {
     'en-US': enUS,
     'fa-IR': faIR,
+    'zh-CN': zhCN,
 };
 
 const App: React.FC = () => {

+ 3 - 0
src/assets/iconify/multi-color/check-off.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="12" cy="12" r="11" fill="#1C1E25" stroke="#646776" style="fill:#1C1E25;fill:color(display-p3 0.1098 0.1176 0.1451);fill-opacity:1;stroke:#646776;stroke:color(display-p3 0.3922 0.4039 0.4627);stroke-opacity:1;" stroke-width="2"/>
+</svg>

+ 4 - 0
src/assets/iconify/multi-color/check-on.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="24" height="24" rx="12" fill="white" style="fill:white;fill-opacity:1;"/>
+<path d="M12 0C5.4 0 0 5.4 0 12C0 18.6 5.4 24 12 24C18.6 24 24 18.6 24 12C24 5.4 18.6 0 12 0ZM18.48 9.12L11.28 16.32C10.92 16.68 10.32 16.68 9.96 16.32H10.08C9.96 16.32 9.72 16.2 9.6 16.08L5.64 12.12C5.28 11.76 5.28 11.16 5.64 10.68C6 10.32 6.6 10.32 7.08 10.68L10.68 14.28L17.28 7.68C17.64 7.32 18.24 7.32 18.6 7.68L18.72 7.8C18.84 8.16 18.84 8.76 18.48 9.12Z" fill="#0EA5E9" style="fill:#0EA5E9;fill:color(display-p3 0.0549 0.6471 0.9137);fill-opacity:1;"/>
+</svg>

+ 3 - 0
src/assets/iconify/single-color/check.svg

@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.6668 5L7.50016 14.1667L3.3335 10" stroke="#0FA4E9" style="stroke:#0FA4E9;stroke:color(display-p3 0.0588 0.6431 0.9137);stroke-opacity:1;" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 1 - 1
src/components/Footerbar/index.tsx

@@ -18,7 +18,7 @@ const Footerbar = memo(() => {
 
     return (
         <footer className={`bg-black border-t border-white/10 ${isMobile ? 'min-h-[168px]' : 'min-h-[189px]'}`}>
-            <div className={`px-[30px] sm:px-6 lg:px-8 max-w-[1440px] mx-auto ${isMobile ? 'pt-12 pb-12' : 'pt-[49px] pb-[48px]'}`}>
+            <div className={`px-[30px] sm:px-6 lg:px-8 max-w-[1276px] mx-auto ${isMobile ? 'pt-12 pb-12' : 'pt-[49px] pb-[48px]'}`}>
                 {isMobile ? (
                     /* Mobile Layout */
                     <div className="flex flex-col items-start gap-5">

+ 1 - 0
src/components/LanguageSwitch.tsx

@@ -14,6 +14,7 @@ const LanguageSwitch = () => {
         <Select value={i18n.language} onChange={handleChange} style={{ width: 120 }}>
             <Option value="fa-IR">فارسی</Option>
             <Option value="en-US">English</Option>
+            <Option value="zh-CN">简体中文</Option>
         </Select>
     );
 };

+ 1 - 1
src/components/Topbar/index.tsx

@@ -44,7 +44,7 @@ const Topbar = memo(() => {
     return (
         <Fragment>
             <header className="fixed top-0 start-0 end-0 z-50 bg-black/90 border-b border-white/10 backdrop-blur-sm">
-                <div className="h-[81px] px-[30px] sm:px-6 lg:px-8 flex items-center justify-between max-w-[1440px] mx-auto">
+                <div className="h-[81px] px-[30px] sm:px-6 lg:px-8 flex items-center justify-between max-w-[1276px] mx-auto">
                     {/* Logo */}
                     <div className="flex-shrink-0 flex items-center gap-2">
                         <Icon icon={logoIcon} className="w-8 h-8" />

+ 1 - 0
src/defines/index.ts

@@ -1 +1,2 @@
 export * from './errorShowType';
+export * from './planTagType';

+ 4 - 0
src/defines/planTagType.ts

@@ -0,0 +1,4 @@
+export enum PlanTagType {
+    NONE = 0,
+    MOST_POPULAR = 1,
+}

+ 1 - 1
src/locales/en-US/components.ts

@@ -7,6 +7,6 @@ export default {
         privacyPolicy: 'Privacy Policy',
         termsOfService: 'Terms of Service',
         contact: 'Contact',
-        copyright: '© 2025 NOMO VPN Inc., All rights reserved',
+        copyright: '© 2026 NOMO VPN Inc., All Rights Reserved',
     },
 };

+ 1 - 0
src/locales/en-US/menus.ts

@@ -3,6 +3,7 @@ export default {
     ['500']: '500',
     ['404']: '404',
     ['home']: 'Home',
+    ['pricing']: 'Pricing',
     // 以下是 框架功能演示 页面的路由名称
     ['featureDemo']: 'Feature Demo',
     ['routeDemo']: 'Route Demo',

+ 18 - 0
src/locales/en-US/pages.ts

@@ -17,6 +17,24 @@ export default {
         },
     },
 
+    // 以下是 Pricing 页面的翻译
+    pricing: {
+        title: 'Choose your NOMO VPN Plan',
+        step3: 'Step 3',
+        step3Title: 'Select a plan that works for you',
+        description:
+            'All plans include all NOMO VPN Apps,24/7 customer support,and high-speed unlimited bandwidth',
+        currencyNote: 'All amounts shown are in USD.',
+        orderSummary: {
+            account: 'Your Account',
+            paymentMethod: 'Payment method',
+            currentSubscription: 'Current Subscription',
+            orderTotal: 'Order Total',
+            terms: 'By continuing to pay, you agree to our Terms of Service by default.',
+            goPayNow: 'Go Pay Now',
+        },
+    },
+
     // 以下是 框架功能演示 页面的翻译
     featureDemo: {
         title: 'Feature Demo',

+ 1 - 1
src/locales/fa-IR/components.ts

@@ -7,6 +7,6 @@ export default {
         privacyPolicy: 'سیاست حریم خصوصی',
         termsOfService: 'شرایط استفاده',
         contact: 'تماس با ما',
-        copyright: '© 2025 NOMO VPN Inc., تمامی حقوق محفوظ است',
+        copyright: '© 2026 NOMO VPN Inc., تمامی حقوق محفوظ است',
     },
 };

+ 1 - 0
src/locales/fa-IR/menus.ts

@@ -3,6 +3,7 @@ export default {
     ['500']: '500',
     ['404']: '404',
     ['home']: 'صفحه اصلی',
+    ['pricing']: 'قیمت ها',
     // 以下是 框架功能演示 页面的路由名称
     ['featureDemo']: 'نمایش ویژگی',
     ['routeDemo']: 'نمایش مسیر',

+ 18 - 0
src/locales/fa-IR/pages.ts

@@ -17,6 +17,24 @@ export default {
         },
     },
 
+    // 以下是 Pricing 页面的翻译
+    pricing: {
+        title: 'انتخاب پلان NOMO VPN',
+        step3: 'مرحله ۳',
+        step3Title: 'پلانی را انتخاب کنید که برای شما مناسب است',
+        description:
+            'همه پلان‌ها شامل تمام اپلیکیشن‌های NOMO VPN، پشتیبانی ۲۴/۷ مشتری و پهنای باند نامحدود با سرعت بالا می‌شوند',
+        currencyNote: 'تمام مبالغ به دلار آمریکا نمایش داده می‌شوند.',
+        orderSummary: {
+            account: 'حساب شما',
+            paymentMethod: 'روش پرداخت',
+            currentSubscription: 'اشتراک فعلی',
+            orderTotal: 'مجموع سفارش',
+            terms: 'با ادامه پرداخت، شما به طور پیش‌فرض با شرایط خدمات ما موافقت می‌کنید.',
+            goPayNow: 'پرداخت کن',
+        },
+    },
+
     // 以下是 框架功能演示 页面的翻译
     featureDemo: {
         title: 'نمایش ویژگی',

+ 12 - 0
src/locales/zh-CN.ts

@@ -0,0 +1,12 @@
+import common from './zh-CN/common';
+import components from './zh-CN/components';
+import menus from './zh-CN/menus';
+import pages from './zh-CN/pages';
+
+export default {
+    DIR: 'ltr',
+    common,
+    menus,
+    components,
+    pages,
+};

+ 1 - 0
src/locales/zh-CN/common.ts

@@ -0,0 +1 @@
+export default {};

+ 12 - 0
src/locales/zh-CN/components.ts

@@ -0,0 +1,12 @@
+export default {
+    topbar: {
+        logo: 'NOMO',
+    },
+    footerbar: {
+        logo: 'NOMO VPN',
+        privacyPolicy: '隐私政策',
+        termsOfService: '服务条款',
+        contact: '联系我们',
+        copyright: '© 2026 NOMO VPN Inc., 版权所有',
+    },
+};

+ 13 - 0
src/locales/zh-CN/menus.ts

@@ -0,0 +1,13 @@
+export default {
+    ['403']: '403',
+    ['500']: '500',
+    ['404']: '404',
+    ['home']: '首页',
+    ['pricing']: '价格',
+    // 以下是 框架功能演示 页面的路由名称
+    ['featureDemo']: '功能演示',
+    ['routeDemo']: '路由演示',
+    ['test']: '测试',
+    ['test.test1']: '测试1',
+    ['test.test2']: '测试2',
+};

+ 50 - 0
src/locales/zh-CN/pages.ts

@@ -0,0 +1,50 @@
+export default {
+    error: {
+        ['404']: {
+            title: '404',
+            subtitle: '对不起,您访问的页面不存在',
+            backHome: '返回首页',
+        },
+        ['403']: {
+            title: '403',
+            subtitle: '对不起,您没有权限访问此页面',
+            backHome: '返回首页',
+        },
+        ['500']: {
+            title: '500',
+            subtitle: '对不起,服务器报告了一个错误',
+            backHome: '返回首页',
+        },
+    },
+
+    // 以下是 Pricing 页面的翻译
+    pricing: {
+        title: '选择您的NOMO VPN计划',
+        step3: 'Step 3',
+        step3Title: '选择一个适合您的计划',
+        description:
+            '所有计划都包括所有NOMO VPN应用程序,24/7客户支持,以及高速无限带宽',
+        currencyNote: '所有金额均以美元显示。',
+        orderSummary: {
+            account: '您的账户',
+            paymentMethod: '支付方式',
+            currentSubscription: '当前订阅',
+            orderTotal: '订单总额',
+            terms: '继续支付,您同意我们的服务条款',
+            goPayNow: '立即支付',
+        },
+    },
+
+    // 以下是 框架功能演示 页面的翻译
+    featureDemo: {
+        title: '功能演示',
+        description: '这是一个使用Ant Design和Tailwind CSS的示例页面',
+        features: {
+            title: '关键功能',
+            antd: '使用Ant Design组件库',
+            tailwind: '使用Tailwind CSS框架',
+            i18n: '支持国际化',
+        },
+        getStarted: '开始',
+    },
+};

+ 54 - 0
src/pages/pricing/components/OrderSummary/index.tsx

@@ -0,0 +1,54 @@
+import { memo } from 'react';
+
+import { Button } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import { useResponsive } from '@/hooks/useResponsive';
+
+import OrderSummaryItem from '../OrderSummaryItem';
+import type { Plan } from '../PricingContent/useService';
+import { useService } from './useService';
+
+export interface OrderSummaryProps {
+    selectedPlan: Plan | null;
+}
+
+const OrderSummary = memo(({ selectedPlan }: OrderSummaryProps) => {
+    const { t } = useTranslation();
+    const { isMobile } = useResponsive();
+    const { userAccount, currentSubscription, orderTotal } = useService({ selectedPlan });
+
+    return (
+        <div className="flex flex-col gap-5 bg-[#1B1D22] rounded-xl p-[25px_30px_25px_25px] shadow-[0px_4px_10px_0px_rgba(0,0,0,0.05)]">
+            <div className="flex flex-col gap-5">
+                <OrderSummaryItem label={t('pages.pricing.orderSummary.account')} value={userAccount} />
+                <OrderSummaryItem
+                    label={t('pages.pricing.orderSummary.currentSubscription')}
+                    value={currentSubscription}
+                />
+                <OrderSummaryItem
+                    label={t('pages.pricing.orderSummary.orderTotal')}
+                    value={orderTotal}
+                    valueColor="text-[#0EA5E9]"
+                />
+            </div>
+            <p className="text-[#646776] text-xs font-normal leading-[1.4]">
+                {t('pages.pricing.orderSummary.terms')}
+            </p>
+            <div className={`flex gap-5 ${isMobile ? 'flex-col' : 'flex-row'}`}>
+                <Button
+                    className="bg-[#0EA5E9] text-white text-sm font-medium leading-[1.4] uppercase rounded-[25px] px-[42px] py-[10px] h-auto border-none hover:bg-[#0EA5E9]/80"
+                    onClick={() => {
+                        // Handle payment
+                    }}
+                >
+                    {t('pages.pricing.orderSummary.goPayNow')}
+                </Button>
+            </div>
+        </div>
+    );
+});
+
+OrderSummary.displayName = 'OrderSummary';
+
+export default OrderSummary;

+ 52 - 0
src/pages/pricing/components/OrderSummary/useService.ts

@@ -0,0 +1,52 @@
+import { useMemo } from 'react';
+
+import { createLocalTools } from '@/utils/localUtils';
+import { userKey } from '@/utils/authUtils';
+
+import type { Plan } from '../PricingContent/useService';
+
+const ls = createLocalTools();
+
+export interface UseServiceReturn {
+    userAccount: string;
+    currentSubscription: string;
+    orderTotal: string;
+}
+
+export interface UseServiceParams {
+    selectedPlan: Plan | null;
+}
+
+export function useService({ selectedPlan }: UseServiceParams): UseServiceReturn {
+    const userInfo = ls.getLocal<API.UserInfo>(userKey);
+
+    const userAccount = useMemo(() => {
+        if (!userInfo) return '';
+        if (userInfo.email) return userInfo.email;
+        if (userInfo.phone) return userInfo.phone;
+        if (userInfo.username) return userInfo.username;
+        if (userInfo.userId) return userInfo.userId;
+        if (userInfo.deviceId) return userInfo.deviceId;
+        return '';
+    }, [userInfo]);
+
+    const currentSubscription = useMemo(() => {
+        if (!selectedPlan) {
+            return '';
+        }
+        return `${selectedPlan.subTitle}/${selectedPlan.title}`;
+    }, [selectedPlan]);
+
+    const orderTotal = useMemo(() => {
+        if (!selectedPlan) {
+            return '$0.00';
+        }
+        return `$${selectedPlan.price.toFixed(2)}`;
+    }, [selectedPlan]);
+
+    return {
+        userAccount,
+        currentSubscription,
+        orderTotal,
+    };
+}

+ 20 - 0
src/pages/pricing/components/OrderSummaryItem/index.tsx

@@ -0,0 +1,20 @@
+import { memo } from 'react';
+
+export interface OrderSummaryItemProps {
+    label: string;
+    value: string;
+    valueColor?: string;
+}
+
+const OrderSummaryItem = memo(({ label, value, valueColor = 'text-white' }: OrderSummaryItemProps) => {
+    return (
+        <div className="flex gap-2.5">
+            <span className="text-white text-sm font-semibold leading-[1.4]">{label}:</span>
+            <span className={`${valueColor} text-sm font-semibold leading-[1.4]`}>{value}</span>
+        </div>
+    );
+});
+
+OrderSummaryItem.displayName = 'OrderSummaryItem';
+
+export default OrderSummaryItem;

+ 104 - 0
src/pages/pricing/components/PlanCard/index.tsx

@@ -0,0 +1,104 @@
+import { memo } from 'react';
+
+import { Icon } from '@iconify/react';
+
+import { useResponsive } from '@/hooks/useResponsive';
+
+import checkOnIcon from '@/assets/iconify/multi-color/check-on.svg';
+import checkOffIcon from '@/assets/iconify/multi-color/check-off.svg';
+import { PlanTagType } from '@/defines';
+
+import { useActions } from './useActions';
+
+export interface PlanCardProps {
+    id: number;
+    title: string;
+    subTitle: string;
+    introduce: string;
+    tag?: string;
+    tagType?: PlanTagType;
+    isSelected?: boolean;
+    onClick?: () => void;
+}
+
+const PlanCard = memo(
+    ({ title, subTitle, introduce, tag, tagType, isSelected = false, onClick }: PlanCardProps) => {
+        const { isMobile } = useResponsive();
+        const { containerRef, titleRef, subTitleRef, introduceRef, textSizes } = useActions(
+            isMobile,
+            title,
+            subTitle,
+            introduce
+        );
+
+        return (
+            <div
+                ref={containerRef}
+                className={`relative flex gap-8 box-border border cursor-pointer transition-all ${
+                    isMobile
+                        ? 'py-3 px-4 w-full flex-row items-center justify-between rounded-lg'
+                        : 'flex-col p-[33px] w-[362.66px] rounded-2xl'
+                } ${
+                    isSelected
+                        ? 'bg-white/10 border-[#0FA4E9] border-2'
+                        : `bg-white/10 ${isMobile ? 'border-transparent' : 'border-white/10'} border-2`
+                }`}
+                onClick={onClick}
+            >
+                {tagType !== PlanTagType.NONE && (
+                    <div
+                        className={`absolute top-[-13.5px] bg-[#0FA4E9] px-4 ${isMobile ? 'end-[21.13px] rounded-[4px] h-[24px]' : 'start-[21.13px] rounded-full py-1'}`}
+                    >
+                        <span className="text-black text-[14px] leading-[24px] font-normal uppercase">
+                            {tag}
+                        </span>
+                    </div>
+                )}
+                {isMobile ? (
+                    <>
+                        <div className="flex items-baseline gap-2">
+                            <span className="text-white text-2xl font-bold leading-[1.3] whitespace-nowrap">
+                                {subTitle}
+                            </span>
+                            <span className="text-[#646776] text-base font-normal leading-[1.3] whitespace-nowrap">
+                                {title}
+                            </span>
+                        </div>
+                        <Icon
+                            icon={isSelected ? checkOnIcon : checkOffIcon}
+                            className={`w-6 h-6 flex-shrink-0`}
+                        />
+                    </>
+                ) : (
+                    <div className="flex flex-col gap-4 items-center">
+                        <h3
+                            ref={titleRef}
+                            className="text-white font-normal leading-[1.5] text-center"
+                            style={{ fontSize: `${textSizes.titleSize}px` }}
+                        >
+                            {title}
+                        </h3>
+                        <span
+                            ref={subTitleRef}
+                            className="text-white font-normal leading-[1.11] text-center"
+                            style={{ fontSize: `${textSizes.subTitleSize}px` }}
+                        >
+                            {subTitle}
+                        </span>
+                        <span
+                            ref={introduceRef}
+                            className="text-white/80 font-normal leading-[1.5] text-center"
+                            style={{ fontSize: `${textSizes.introduceSize}px` }}
+                        >
+                            {introduce}
+                        </span>
+                    </div>
+                )}
+            </div>
+        );
+    }
+);
+
+PlanCard.displayName = 'PlanCard';
+
+export default PlanCard;

+ 125 - 0
src/pages/pricing/components/PlanCard/useActions.ts

@@ -0,0 +1,125 @@
+import { useEffect, useRef, useState } from 'react';
+
+import { calculateOptimalFontSize } from '@/utils/domUtils';
+
+interface TextSizes {
+    titleSize: number;
+    subTitleSize: number;
+    introduceSize: number;
+}
+
+interface UseActionsReturn {
+    containerRef: React.RefObject<HTMLDivElement>;
+    titleRef: React.RefObject<HTMLHeadingElement>;
+    subTitleRef: React.RefObject<HTMLSpanElement>;
+    introduceRef: React.RefObject<HTMLSpanElement>;
+    textSizes: TextSizes;
+}
+
+const DEFAULT_TITLE_SIZE = 28;
+const DEFAULT_SUBTITLE_SIZE = 36;
+const DEFAULT_INTRODUCE_SIZE = 22;
+const CONTAINER_PADDING = 33 * 2; // 左右 padding 总和
+
+/**
+ * PlanCard 响应逻辑 Hook
+ * 处理文本自适应大小调整
+ */
+export function useActions(
+    isMobile: boolean,
+    title: string,
+    subTitle: string,
+    introduce: string
+): UseActionsReturn {
+    const containerRef = useRef<HTMLDivElement>(null);
+    const titleRef = useRef<HTMLHeadingElement>(null);
+    const subTitleRef = useRef<HTMLSpanElement>(null);
+    const introduceRef = useRef<HTMLSpanElement>(null);
+
+    const [textSizes, setTextSizes] = useState<TextSizes>({
+        titleSize: DEFAULT_TITLE_SIZE,
+        subTitleSize: DEFAULT_SUBTITLE_SIZE,
+        introduceSize: DEFAULT_INTRODUCE_SIZE,
+    });
+
+    useEffect(() => {
+        if (isMobile) {
+            setTextSizes({
+                titleSize: DEFAULT_TITLE_SIZE,
+                subTitleSize: DEFAULT_SUBTITLE_SIZE,
+                introduceSize: DEFAULT_INTRODUCE_SIZE,
+            });
+            return;
+        }
+
+        const container = containerRef.current;
+        const titleEl = titleRef.current;
+        const subTitleEl = subTitleRef.current;
+        const introduceEl = introduceRef.current;
+
+        if (!container || !titleEl || !subTitleEl || !introduceEl) {
+            return;
+        }
+
+        const calculateOptimalSizes = () => {
+            const containerWidth = container.offsetWidth;
+            const availableWidth = containerWidth - CONTAINER_PADDING;
+
+            if (availableWidth <= 0) {
+                return;
+            }
+
+            const newTitleSize = calculateOptimalFontSize({
+                element: titleEl,
+                text: title,
+                defaultSize: DEFAULT_TITLE_SIZE,
+                availableWidth,
+            });
+
+            const newSubTitleSize = calculateOptimalFontSize({
+                element: subTitleEl,
+                text: subTitle,
+                defaultSize: DEFAULT_SUBTITLE_SIZE,
+                availableWidth,
+            });
+
+            const newIntroduceSize = calculateOptimalFontSize({
+                element: introduceEl,
+                text: introduce,
+                defaultSize: DEFAULT_INTRODUCE_SIZE,
+                availableWidth,
+            });
+
+            setTextSizes({
+                titleSize: newTitleSize,
+                subTitleSize: newSubTitleSize,
+                introduceSize: newIntroduceSize,
+            });
+        };
+
+        const resizeObserver = new ResizeObserver(() => {
+            requestAnimationFrame(() => {
+                calculateOptimalSizes();
+            });
+        });
+
+        resizeObserver.observe(container);
+        resizeObserver.observe(titleEl);
+        resizeObserver.observe(subTitleEl);
+        resizeObserver.observe(introduceEl);
+
+        calculateOptimalSizes();
+
+        return () => {
+            resizeObserver.disconnect();
+        };
+    }, [isMobile, title, subTitle, introduce]);
+
+    return {
+        containerRef,
+        titleRef,
+        subTitleRef,
+        introduceRef,
+        textSizes,
+    };
+}

+ 75 - 0
src/pages/pricing/components/PricingContent/index.tsx

@@ -0,0 +1,75 @@
+import { memo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useResponsive } from '@/hooks/useResponsive';
+
+import PlanCard from '../PlanCard';
+import OrderSummary from '../OrderSummary';
+import { useAction } from './useAction';
+import { useService } from './useService';
+
+const PricingContent = memo(() => {
+    const { t } = useTranslation();
+    const { isMobile } = useResponsive();
+    const { selectedPlanId, handlePlanClick } = useAction();
+    const { plans } = useService();
+
+    const selectedPlan = plans.find((plan) => plan.id === selectedPlanId) || null;
+
+    return (
+        <div className="max-w-[1440px] w-full px-[30px]">
+            <div className="bg-[#0F1116] px-5 sm:px-6 lg:px-[100px] py-[30px] pb-[100px] my-[50px] rounded-[12px] flex flex-col gap-10">
+                <div className="flex flex-col gap-5">
+                    <h1 className="text-white text-[35px] font-semibold leading-[1.43] text-center uppercase">
+                        {t('pages.pricing.title')}
+                    </h1>
+                </div>
+                <div className="flex flex-col gap-10">
+                    <div className="flex flex-col gap-2.5">
+                        <div className="flex gap-2.5">
+                            <span className="text-[#0EA5E9] text-[22px] font-semibold leading-[1.4]">
+                                {t('pages.pricing.step3')}
+                            </span>
+                            <span className="text-white text-[22px] font-semibold leading-[1.4] uppercase">
+                                {t('pages.pricing.step3Title')}
+                            </span>
+                        </div>
+                        <p className="text-[#646776] text-sm font-medium leading-[1.4]">
+                            {t('pages.pricing.description')}
+                        </p>
+                        <p className="text-[#646776] text-sm font-medium leading-[1.4]">
+                            {t('pages.pricing.currencyNote')}
+                        </p>
+                    </div>
+                    <div
+                        className={`flex ${
+                            isMobile
+                                ? 'flex-col items-center'
+                                : 'flex-row items-center justify-center'
+                        } gap-8`}
+                    >
+                        {plans.map((plan) => (
+                            <PlanCard
+                                key={plan.id}
+                                id={plan.id}
+                                title={plan.title}
+                                subTitle={plan.subTitle}
+                                introduce={plan.introduce}
+                                tag={plan.tag}
+                                tagType={plan.tagType}
+                                isSelected={selectedPlanId === plan.id}
+                                onClick={() => handlePlanClick(plan.id)}
+                            />
+                        ))}
+                    </div>
+                </div>
+                <OrderSummary selectedPlan={selectedPlan} />
+            </div>
+        </div>
+    );
+});
+
+PricingContent.displayName = 'PricingContent';
+
+export default PricingContent;

+ 19 - 0
src/pages/pricing/components/PricingContent/useAction.ts

@@ -0,0 +1,19 @@
+import { useState, useCallback } from 'react';
+
+export interface UseActionReturn {
+    selectedPlanId: number | null;
+    handlePlanClick: (planId: number) => void;
+}
+
+export function useAction(): UseActionReturn {
+    const [selectedPlanId, setSelectedPlanId] = useState<number | null>(null);
+
+    const handlePlanClick = useCallback((planId: number) => {
+        setSelectedPlanId(planId);
+    }, []);
+
+    return {
+        selectedPlanId,
+        handlePlanClick,
+    };
+}

+ 65 - 0
src/pages/pricing/components/PricingContent/useService.ts

@@ -0,0 +1,65 @@
+import { useMemo } from 'react';
+
+import { PlanTagType } from '@/defines';
+
+export interface Plan {
+    id: number;
+    title: string;
+    subTitle: string;
+    introduce: string;
+    tag?: string;
+    tagType?: PlanTagType;
+    price: number;
+}
+
+export interface UseServiceReturn {
+    plans: Plan[];
+}
+
+export function useService(): UseServiceReturn {
+    const plans = useMemo<Plan[]>(
+        () => [
+            {
+                id: 1,
+                title: '12个月',
+                subTitle: 'USD 53.99',
+                introduce: '折合 USD 0.15/天',
+                tag: '0.6折优惠',
+                tagType: PlanTagType.MOST_POPULAR,
+                price: 53.99,
+            },
+            {
+                id: 2,
+                title: '3个月',
+                subTitle: 'USD 15.99',
+                introduce: '折合 USD 0.18/天',
+                tag: '0.8折优惠',
+                tagType: PlanTagType.MOST_POPULAR,
+                price: 15.99,
+            },
+            {
+                id: 3,
+                title: '1个月',
+                subTitle: 'USD 2.99',
+                introduce: '折合 USD 0.22/天',
+                tag: '0.9折优惠',
+                tagType: PlanTagType.MOST_POPULAR,
+                price: 2.99,
+            },
+            {
+                id: 4,
+                title: '7天',
+                subTitle: 'USD 1.99',
+                introduce: '折合 USD 0.28/天',
+                tag: '无优惠',
+                tagType: PlanTagType.NONE,
+                price: 1.99,
+            },
+        ],
+        []
+    );
+
+    return {
+        plans,
+    };
+}

+ 15 - 0
src/pages/pricing/index.tsx

@@ -0,0 +1,15 @@
+import { memo } from 'react';
+
+import PricingContent from './components/PricingContent';
+
+const Pricing = memo(() => {
+    return (
+        <div className="flex items-start justify-center">
+            <PricingContent />
+        </div>
+    );
+});
+
+Pricing.displayName = 'Pricing';
+
+export default Pricing;

+ 6 - 0
src/router/routes.tsx

@@ -10,6 +10,7 @@ import Redirect from '@/pages/redirect';
 import Home from '@/pages/home';
 import RouteDemo from '@/pages/routeDemo';
 import FeatureDemo from '@/pages/featureDemo';
+import Pricing from '@/pages/pricing';
 
 const routes: AppRouteObject[] = [
     {
@@ -25,6 +26,11 @@ const routes: AppRouteObject[] = [
                 path: '/home',
                 element: <Home />,
             },
+            {
+                name: 'pricing',
+                path: '/pricing',
+                element: <Pricing />,
+            },
             {
                 name: 'featureDemo',
                 path: '/feature-demo',

+ 89 - 0
src/utils/domUtils.ts

@@ -0,0 +1,89 @@
+interface MeasureTextWidthParams {
+    text: string;
+    fontSize: number;
+    fontFamily: string;
+    fontWeight: string;
+    fontStyle: string;
+    letterSpacing: string;
+}
+
+interface CalculateOptimalFontSizeParams {
+    element: HTMLElement;
+    text: string;
+    defaultSize: number;
+    availableWidth: number;
+    minSize?: number;
+}
+
+/**
+ * 测量文本的实际渲染宽度
+ * @param params 测量参数
+ * @returns 文本宽度(像素)
+ */
+export function measureTextWidth({
+    text,
+    fontSize,
+    fontFamily,
+    fontWeight,
+    fontStyle,
+    letterSpacing,
+}: MeasureTextWidthParams): number {
+    if (!text || text.trim() === '') {
+        return 0;
+    }
+
+    const tempEl = document.createElement('span');
+    tempEl.style.visibility = 'hidden';
+    tempEl.style.position = 'absolute';
+    tempEl.style.top = '-9999px';
+    tempEl.style.left = '-9999px';
+    tempEl.style.whiteSpace = 'nowrap';
+    tempEl.style.fontSize = `${fontSize}px`;
+    tempEl.style.fontFamily = fontFamily;
+    tempEl.style.fontWeight = fontWeight;
+    tempEl.style.fontStyle = fontStyle;
+    tempEl.style.letterSpacing = letterSpacing;
+    tempEl.textContent = text;
+    document.body.appendChild(tempEl);
+
+    const textWidth = tempEl.offsetWidth;
+    document.body.removeChild(tempEl);
+
+    return textWidth;
+}
+
+/**
+ * 计算文本的最优字体大小,使其适配指定宽度
+ * @param params 计算参数
+ * @returns 最优字体大小(像素)
+ */
+export function calculateOptimalFontSize({
+    element,
+    text,
+    defaultSize,
+    availableWidth,
+    minSize = 12,
+}: CalculateOptimalFontSizeParams): number {
+    if (!text || text.trim() === '' || availableWidth <= 0) {
+        return defaultSize;
+    }
+
+    const style = window.getComputedStyle(element);
+    const textWidth = measureTextWidth({
+        text,
+        fontSize: defaultSize,
+        fontFamily: style.fontFamily,
+        fontWeight: style.fontWeight,
+        fontStyle: style.fontStyle,
+        letterSpacing: style.letterSpacing,
+    });
+
+    if (textWidth + 5 <= availableWidth) {
+        return defaultSize;
+    }
+
+    const ratio = availableWidth / textWidth;
+    const newSize = Math.max(minSize, Math.floor(defaultSize * ratio * 0.95));
+
+    return newSize;
+}